diff --git a/ROADMAP.md b/ROADMAP.md
index f5560b7..726d9b6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -98,15 +98,154 @@ simplification worth revisiting. Anything unticked is still open.
enemy walking on it), coastal cannon (auto-fires on warships).
- [x] Embark / disembark orders wired on server and client, with a cargo readout
in the unit panel.
+- [ ] Easy and ergonomic way to split unit stacks.
+- [ ] Give stacks with range units a dedicated button to perform ranged attacks,
+ right-clicking will now ONLY move (obviously triggers a battle when moving on the same tile)
## 6. Content and tuning
- [x] Market and Bank base cost reduced tenfold (500k and 1M).
+## 7. Resources
+
+- [x] Add the following resource groups:
+ - [x] Energy. Uses the electric icon. In kWh, MWh, GWh, TWh.
+ - [x] Steel. Uses the I-beam icon. In tonnes.
+ - [x] Food. Uses the apple icon. In tonnes.
+ - [x] Luxury. Uses the diamond icon. In carats.
+ - [x] High-tech. Uses the microchip icon. In units.
+
+* [x] Resources are stored on every tile that is either a city or a resource production building.
+ * [x] Cities and tiles automatically try to buy necessary resources from their neighbours, including non-hostile cities.
+ * [x] Energy produced by power plants cannot be stored.
+* [x] Keep track of resource prices in the global market.
+ * [x] Transporting resources costs additional money, used to automatically buy energy.
+* [~] Fossil fuels are abstracted into the transformative plants' money upkeep
+ rather than a stored fuel resource: the roadmap lists only the five
+ groups above, so coal, gas and oil are not separately tracked.
+* [~] Energy is modelled as a daily flow everywhere, so no city can bank it.
+ Only the other four groups have per-tile storage.
+
+
+### Delivering resources
+* [x] Delivering 1 tonne of resources over 1 tile requires 10 MWh by truck over roads, 50 MWh * tile movement cost without roads, 1 MWh by train, 50 kWh by ship.
+* [x] A delivery graph, revealed only in the economic map mode, is created.
+ * [x] The delivery graph is built
+* [~] A city always buys from the cheapest source: a nearby store whose
+ delivered price beats the market, or the global market itself. Market
+ buying uses a nominal two-tile road delivery.
+
+### Food
+* [x] Each person requires 1 kg of food per day. That's how much cities have to buy. Cities attempt to buy up to 130 % of their needs for the month.
+* [x] Producing 1 tonne of food requires 1 MWh of energy.
+
+### Energy
+* [x] Cities require energy to function. Add a new economic metric (not in the top though), kWh/€ of GDP. This determines the base energy consumption of cities.
+* [x] Electric energy is delivered instantly to the entire connected region when requested, at no cost.
+ Every country starts having each of their contiguous cities be connected.
+* [x] Without electric energy, cities and buildings attempt to buy energy that has to be delivered. 10 GWh of energy count as 1 tonne of material.
+* [x] Energy is needed to create all other resources
+
+### Steel
+* [x] Everything requires steel to be built.
+* [x] 20 MWh/tonne of steel at the steel mill
+* [x] Each inhabitant consumes 1 kg of steel per day.
+
+### Luxury
+* [x] Inhabitants require 0.01 ct of luxury per day. No building or military unit requires that.
+
+### High-tech
+* [x] Science buildings require high-tech so they can be built, and they will consume 1 unit of high-tech per tech point they produce.
+
+### Buildings
+* [x] New resource-producing buildings, built only _outside_ of cities:
+ * [x] Energy buildings:
+ * [x] Electricity buildings
+ * [x] Transformative plants: turn storable fossil fuels into electricity.
+ * [x] Coal power plant
+ * [x] Natural gas power plant
+ * [x] Oil power plant
+ * [x] Renewable energy plants: create a limited amount of electricity for free
+ * [x] Solar power plant
+ * [x] Geothermal plant
+ * [x] Wind turbines
+ * [x] Offshore wind turbines
+ * [x] Fusion power plant
+ * [x] Steel buildings:
+ * [x] Steel mill
+ * [x] Food buildings:
+ * [x] Food is automatically produced by tiles, in the same proportions as their population.
+ * [x] Luxury buildings:
+ * [x] Gold mine. 10 MWh/ct
+ * [x] Diamond mine. 5 MWh/ct
+ * [x] High-tech building:
+ * [x] Chip foundry.
+
+* [x] A dedicated resource panel in the nation modal: the Resources tab shows
+ each group's stores, daily need, upkeep and production beside the global
+ market's price and the world's supply and demand.
+* [x] Every nation opens with the resource works its economy needs, laid out
+ randomly from the world seed at map generation (converters first, then
+ enough power for each connected grid) so the opening economy covers its
+ people's, industry's and upkeep's needs. Works beside the transport
+ network get a one-tile road spur so their output can reach a city, and the
+ per-capita balance is re-derived afterwards so no nation starts richer
+ just because its land hosts more industry. Food is not covered by any
+ building, so a food-poor nation still buys on the market.
+* [x] A port also lifts its region's food output: 5% per adjacent sea tile per
+ level, on top of its GDP-per-capita bonus.
+
+### Upkeep and repairs
+* [x] Buildings, units, tile works and the transport network no longer cost
+ money to keep; they wear out in materials (steel, with high-tech for
+ advanced things) that the state buys from its stores and the market.
+ Money upkeep is now only propaganda campaigns.
+* [x] Construction spends materials as well as money (steel proportional to the
+ cost, high-tech for advanced items).
+* [x] Repairs cost materials: a razed city building level is rebuilt once paid
+ for, a damaged unit heals at a steel/high-tech cost, a damaged tile work
+ heals once its steel debt is cleared, and a tile with unpaid damage does
+ not recover its GDP.
+* [x] Infantry units carry 1,000 soldiers. Training one draws that many people
+ at random out of the region's population, and healing a damaged unit draws
+ replacement soldiers the same way. A unit's soldiers need double rations in
+ combat and triple while wounded and resting in one of their own cities.
+* [x] Supply applies to every land unit: it carries 50 days of food. At home, or
+ in foreign territory with a clear route (land or maritime) to an allied
+ city, it eats from the nearest such city and tops its pack up; cut off, the
+ days run down and then the soldiers start dying of starvation.
+* [x] Encirclement is local: a tile is encircled when enemy units hold every
+ land tile beside it and it has no working port. An encircled city or unit
+ loses its neighbours and the foreign market and eats its own reserves. A
+ port keeps its tile supplied unless enemy warships hold every sea tile
+ around the city (a blockade).
+* [x] Every naval city opens the game with a port. A working port on a city's
+ continent is the only link to other continents: without one, cities trade
+ over land on their own continent only and cannot use the foreign market
+ (nor can construction, upkeep, combat or unit supply draw on it).
+* [x] Every delivery runs on the transport network: roads and railways on land,
+ shipping lanes across water (which need a working port). There is no
+ off-road trucking, so a resource building or city off the network cannot be
+ bought from until it is linked, and a nation's market is reached along the
+ same roads and rails. Goods never cross the land of a nation you are at war
+ with.
+* [x] Guns and aircraft are cheap parked and costly to fire: air defense,
+ artillery and aircraft have little standing upkeep and spend munitions
+ (steel/high-tech) on every volley or sortie, with no shot when the
+ materials cannot be found.
+* [x] The fusion power plant is gated behind Fusion Power, a 500,000-point
+ technology.
+* [ ] A resource budget line in the national budget breakdown (the money spent
+ on upkeep, electric imports, goods and their transport is tracked by the
+ model but the actual spend is not yet its own row in the budget table; the
+ table currently forecasts upkeep at market prices).
+
+
## Open items
- [ ] A dedicated structure hit-point model for cities and tile improvements
(see the `[~]` note above).
- [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`,
- `icon_launch.svg`, `icon_fortifications.svg`, `icon_radar_tower.svg`,
- `icon_coastal_cannon.svg`, `icon_road.svg`, `icon_railway.svg`.
+ `icon_coastal_cannon.svg`, `icon_chip_foundry.svg`,
+ `icon_geothermal_plant.svg`, `icon_offshore_wind_turbines.svg`,
+ `icon_wind_turbines.svg`.
diff --git a/client/assets/icon_chip_foundry.svg b/client/assets/icon_chip_foundry.svg
new file mode 100644
index 0000000..07a17b6
--- /dev/null
+++ b/client/assets/icon_chip_foundry.svg
@@ -0,0 +1,5 @@
+
diff --git a/client/assets/icon_coal_power_plant.svg b/client/assets/icon_coal_power_plant.svg
new file mode 100644
index 0000000..265943b
--- /dev/null
+++ b/client/assets/icon_coal_power_plant.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_diamond_mine.svg b/client/assets/icon_diamond_mine.svg
new file mode 100644
index 0000000..564fcd5
--- /dev/null
+++ b/client/assets/icon_diamond_mine.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_electricity.svg b/client/assets/icon_electricity.svg
new file mode 100644
index 0000000..da72685
--- /dev/null
+++ b/client/assets/icon_electricity.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_food.svg b/client/assets/icon_food.svg
new file mode 100644
index 0000000..d87c4dd
--- /dev/null
+++ b/client/assets/icon_food.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_fusion_power_plant.svg b/client/assets/icon_fusion_power_plant.svg
new file mode 100644
index 0000000..85c5097
--- /dev/null
+++ b/client/assets/icon_fusion_power_plant.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_gas_power_plant.svg b/client/assets/icon_gas_power_plant.svg
new file mode 100644
index 0000000..265943b
--- /dev/null
+++ b/client/assets/icon_gas_power_plant.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_geothermal_plant.svg b/client/assets/icon_geothermal_plant.svg
new file mode 100644
index 0000000..342d093
--- /dev/null
+++ b/client/assets/icon_geothermal_plant.svg
@@ -0,0 +1,5 @@
+
diff --git a/client/assets/icon_gold_mine.svg b/client/assets/icon_gold_mine.svg
new file mode 100644
index 0000000..2394602
--- /dev/null
+++ b/client/assets/icon_gold_mine.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_hightech.svg b/client/assets/icon_hightech.svg
new file mode 100644
index 0000000..e46936e
--- /dev/null
+++ b/client/assets/icon_hightech.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_luxury.svg b/client/assets/icon_luxury.svg
new file mode 100644
index 0000000..5febec7
--- /dev/null
+++ b/client/assets/icon_luxury.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_offshore_wind_turbines.svg b/client/assets/icon_offshore_wind_turbines.svg
new file mode 100644
index 0000000..5071b2a
--- /dev/null
+++ b/client/assets/icon_offshore_wind_turbines.svg
@@ -0,0 +1,5 @@
+
diff --git a/client/assets/icon_oil_power_plant.svg b/client/assets/icon_oil_power_plant.svg
new file mode 100644
index 0000000..265943b
--- /dev/null
+++ b/client/assets/icon_oil_power_plant.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_solar_power_plant.svg b/client/assets/icon_solar_power_plant.svg
new file mode 100644
index 0000000..54416a0
--- /dev/null
+++ b/client/assets/icon_solar_power_plant.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_steel.svg b/client/assets/icon_steel.svg
new file mode 100644
index 0000000..42e1b84
--- /dev/null
+++ b/client/assets/icon_steel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_steel_mill.svg b/client/assets/icon_steel_mill.svg
new file mode 100644
index 0000000..408bba6
--- /dev/null
+++ b/client/assets/icon_steel_mill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/icon_wind_turbines.svg b/client/assets/icon_wind_turbines.svg
new file mode 100644
index 0000000..d8f84ec
--- /dev/null
+++ b/client/assets/icon_wind_turbines.svg
@@ -0,0 +1,5 @@
+
diff --git a/client/css/style.css b/client/css/style.css
index bf3e83e..a338ead 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -357,6 +357,24 @@ input:focus, select:focus { border-color: var(--accent); }
/* A barrage the player is looking at stands out from the rest. */
.target-line.target-selected, .target-ring.target-selected { opacity: 1; }
+/* The delivery graph, shown only in the economic map modes: a coloured line
+ per delivery leg and a dot on every storage node. */
+#layer-resources { pointer-events: none; }
+.resource-node {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ margin-left: -5px;
+ margin-top: -5px;
+ box-sizing: border-box;
+ border: 2px solid #ffffff;
+ border-radius: 50%;
+ background: #2b3550;
+ pointer-events: none;
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
+}
+.resource-icon { vertical-align: middle; }
+
/* Outlines the selected or inspected tile. The polygon is built in JS from the
same warped hexagon as the terrain, so it follows the geography. */
.tile-selection {
@@ -746,13 +764,21 @@ input:focus, select:focus { border-color: var(--accent); }
padding: 12px 18px;
}
-/* One button per buildable tile improvement, laid out in a wrapping row. */
+/* Tile improvements, grouped under a category heading. */
.tile-improvement-actions {
display: flex;
- flex-wrap: wrap;
- gap: 6px;
+ flex-direction: column;
+ gap: 8px;
margin-top: 8px;
}
+.improvement-group { display: flex; flex-direction: column; gap: 4px; }
+.improvement-category {
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.improvement-row { display: flex; flex-wrap: wrap; gap: 6px; }
.improvement-btn { font-size: 12px; }
.improvement-btn.current { opacity: 0.6; }
.improvement-btn.remove { border-color: rgba(242, 114, 114, 0.6); }
@@ -766,7 +792,32 @@ input:focus, select:focus { border-color: var(--accent); }
padding: 5px 4px;
}
.improvement-icon { width: 40px; height: 40px; display: block; }
-.improvement-cost { font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; }
+.improvement-cost {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 0 3px;
+ font-size: 10px;
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+.cost-res { display: inline-flex; align-items: center; gap: 2px; }
+.cost-icon { width: 12px; height: 12px; vertical-align: middle; }
+.cost-money { color: var(--muted); }
+
+/* The tile panel leads with what the tile actually produces. */
+#tile-production { margin: 4px 0 6px; display: flex; flex-direction: column; gap: 2px; }
+.tile-prod-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+}
+.tile-prod-row .tile-prod-name { color: var(--muted); }
+.tile-prod-row .tile-prod-value { margin-left: auto; }
+.tile-prod-empty { font-size: 12px; color: var(--muted); }
.info-section.hidden { display: none !important; }
.info-title { font-size: 17px; font-weight: 600; margin-bottom: 4px; }
@@ -1228,6 +1279,16 @@ input:focus, select:focus { border-color: var(--accent); }
.conflict-detail .conflict-line { font-size: 12px; color: var(--muted); }
.conflict-detail .conflict-side { font-size: 13px; margin-top: 4px; }
+.economy-table { width: 100%; border-collapse: collapse; margin-top: 6px; }
+.economy-table th,
+.economy-table td { padding: 5px 8px; text-align: left; border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
+.economy-table th { color: var(--muted); font-weight: 600; font-size: 11px; }
+.economy-table .numeric { text-align: right; font-variant-numeric: tabular-nums; }
+.central-bank-rate { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
+.central-bank-rate label { color: var(--muted); }
+.central-bank-rate input { width: 90px; }
+.subhead { margin-top: 10px; color: var(--muted); font-weight: 600; }
+
.diplomacy-table { width: 100%; border-collapse: collapse; }
.diplomacy-table th,
.diplomacy-table td {
@@ -1294,6 +1355,24 @@ input:focus, select:focus { border-color: var(--accent); }
font-weight: 700;
}
.budget-table tfoot .budget-total .budget-value { color: #fff; }
+.budget-table tr.budget-subsection td {
+ padding-top: 8px;
+ color: var(--muted);
+ font-size: 11px;
+}
+.budget-months {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 4px 18px 0;
+}
+.budget-months:empty { display: none; }
+.budget-month-nav {
+ min-width: 28px;
+ padding: 2px 8px;
+ font-size: 12px;
+}
+.budget-month-label { color: var(--muted); font-size: 12px; min-width: 120px; text-align: center; }
.training-status { padding: 0 18px 16px; }
.training-queue { padding-top: 0; }
@@ -1328,6 +1407,10 @@ input:focus, select:focus { border-color: var(--accent); }
gap: 10px;
text-align: left;
}
+#city-train-list .train-name { font-weight: 600; }
+#city-train-list .train-resources { display: inline-flex; align-items: center; gap: 8px; }
+#city-train-list .train-price { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+#city-train-list .train-time { color: var(--muted); font-size: 12px; margin-left: auto; font-variant-numeric: tabular-nums; }
/* Local test game: the free, instant twin of an order button. */
.free-button {
border: 1px dashed rgba(130, 225, 170, 0.75) !important;
diff --git a/client/index.html b/client/index.html
index 25a4547..32c3a10 100644
--- a/client/index.html
+++ b/client/index.html
@@ -100,6 +100,7 @@
+
@@ -118,9 +119,9 @@
Date
Population0
-
GDP€0
-
GDP/capita€0
-
Budget€0
+
GDP¤0
+
GDP/capita¤0
+
Budget¤0
Popularity—
@@ -141,6 +142,7 @@
+
@@ -224,6 +226,7 @@
+
@@ -245,6 +248,10 @@
+
+
+
+
@@ -307,6 +314,8 @@
+
+
@@ -335,8 +344,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/js/credits.js b/client/js/credits.js
index 7b65933..9eeeb03 100644
--- a/client/js/credits.js
+++ b/client/js/credits.js
@@ -7,20 +7,23 @@ export const CREDIT_SECTIONS = [
{
title: "Visual artists",
note:
- "Unit, building and nation icons drawn for game-icons.net by the artists " +
- "below, used under CC BY 3.0.",
+ "Unit, building and resource icons drawn for game-icons.net by the " +
+ "artists below, used under CC BY 3.0.",
entries: [
{
name: "Delapouite",
detail:
"bank, caesar, clapperboard, control tower, delivery drone, " +
- "encirclement, graduate cap, habitat dome, jet fighter, military " +
- "fort, modern city, old wagon, rail road, road, ship bow, shop, " +
- "stone bust, throne",
+ "encirclement, gold mine, graduate cap, habitat dome, i-beam, jet " +
+ "fighter, melting metal, military fort, modern city, nuclear plant, " +
+ "old wagon, power plant, rail road, road, ship bow, shop, stone " +
+ "bust, throne",
},
{
name: "Lorc",
- detail: "anchor, crossed sabres, radar dish, rocket",
+ detail:
+ "anchor, crossed sabres, cut diamond, diamond hard, microchip, " +
+ "radar dish, rocket, shiny apple",
},
{
name: "Lord Berandas",
@@ -29,7 +32,8 @@ export const CREDIT_SECTIONS = [
{ name: "Cathelineau", detail: "battleship" },
{ name: "Pierre Leducq", detail: "anti-aircraft gun" },
{ name: "Quoting", detail: "field gun" },
- { name: "Skoll", detail: "FAMAS" },
+ { name: "sbed", detail: "electric" },
+ { name: "Skoll", detail: "FAMAS, solar power" },
],
},
{
diff --git a/client/js/currency.js b/client/js/currency.js
new file mode 100644
index 0000000..4c8c642
--- /dev/null
+++ b/client/js/currency.js
@@ -0,0 +1,22 @@
+// The viewer's currency. Money is quoted in whatever nation the player runs, so
+// every money string reads in the right unit. The symbol is set from the
+// snapshot; until then a neutral mark stands in.
+
+import { groupDigits } from "../../shared/text_format.js";
+
+let symbol = "¤";
+
+export function setCurrency(currency) {
+ if (currency && currency.symbol) symbol = currency.symbol;
+}
+
+export function currencySymbol() {
+ return symbol;
+}
+
+// A signed money amount, with an optional "/h" for hourly figures.
+export function moneyText(value, { signed = true, perHour = false } = {}) {
+ const amount = `${symbol}${groupDigits(Math.round(Math.abs(value)))}`;
+ const sign = signed ? (value < 0 ? "−" : "+") : "";
+ return `${sign}${amount}${perHour ? "/h" : ""}`;
+}
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index 87e3178..edc45ec 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -17,6 +17,7 @@ import { panelMethods } from "./game_screen/panels.js";
import { CompletionWatcher } from "./game_screen/completions.js";
import { BombardmentSfx, AirStrikeSfx, zoomVolume } from "./game_screen/bombing.js";
import { audio } from "./audio.js";
+import { setCurrency } from "./currency.js";
export class GameScreen {
constructor(network, config) {
@@ -37,15 +38,16 @@ export class GameScreen {
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
- this._gdpBaseline = new Map();
+ this._productionBaseline = new Map();
+ this._regionProductionBase = new Map();
this._tileEthnicity = new Map();
this._populationVersion = undefined;
- this._gdpBaselineVersion = undefined;
+ this._productionBaselineVersion = undefined;
this._ethnicityVersion = undefined;
this._territoryVersion = undefined;
this._regionVersion = undefined;
- this._tileGdpPenalties = new Map();
- this._tileBattleGdpDeficits = new Map();
+ this._tileProductionPenalties = new Map();
+ this._tileBattleProductionDeficits = new Map();
this._territoryOwner = new Map();
this._tileRegion = new Map();
this._gdpExpanded = false;
@@ -92,6 +94,7 @@ export class GameScreen {
highlight: $("#layer-highlight"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
+ resources: $("#layer-resources"),
entities: $("#layer-entities"),
labels: $("#layer-labels"),
}
@@ -159,6 +162,7 @@ export class GameScreen {
onState(state) {
this.snapshot = state;
this.localCiv = state.viewer;
+ setCurrency(state.viewerStats && state.viewerStats.currency);
this.testing = !!(state.testing || (this.config && this.config.testing));
this.civilisations = state.civs.map(
(c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" }
@@ -176,14 +180,17 @@ export class GameScreen {
this._populationVersion = versionOf("population");
this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]]));
}
- // The opening GDP per capita of every land tile never changes, so it is
- // built once and kept across the deltas that omit it.
- if (versionChanged("gdpBaseline", this._gdpBaselineVersion)) {
- this._gdpBaselineVersion = versionOf("gdpBaseline");
- this._gdpBaseline = new Map(
- (state.gdpBaseline || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
+ // The opening production per head of every land tile never changes, so it
+ // is built once and kept across the deltas that omit it.
+ if (versionChanged("productionBaseline", this._productionBaselineVersion)) {
+ this._productionBaselineVersion = versionOf("productionBaseline");
+ this._productionBaseline = new Map(
+ (state.productionBaseline || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
}
+ // Each region's production per head, before tile modifiers. It moves with
+ // production and prices, so it is rebuilt from every snapshot.
+ this._regionProductionBase = new Map((state.regionProductionBase || []).map((e) => [e[0], e[1]]));
if (versionChanged("ethnicity", this._ethnicityVersion)) {
this._ethnicityVersion = versionOf("ethnicity");
this._tileEthnicity = new Map(
@@ -198,11 +205,11 @@ export class GameScreen {
this._regionVersion = versionOf("regions");
this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]]));
}
- this._tileGdpPenalties = new Map(
- (state.tileGdpPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
+ this._tileProductionPenalties = new Map(
+ (state.tileProductionPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
- this._tileBattleGdpDeficits = new Map(
- (state.tileBattleGdpDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
+ this._tileBattleProductionDeficits = new Map(
+ (state.tileBattleProductionDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
);
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
this._cityBuildings = new Map();
@@ -227,6 +234,9 @@ export class GameScreen {
// Mirror the viewer's repeatable-research bonuses used by prediction.
this.map.railSpeed = (state.viewerStats || {}).railSpeed || 0;
this.map.applySnapshot(state);
+ // The delivery graph is delta-dropped while unchanged, so only replace the
+ // view's copy when the snapshot actually carried one.
+ if (state.resourceGraph) this.map.setResourceGraph(state.resourceGraph);
// Refresh an active economic map mode against the new figures.
this._refreshEconomicMode();
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index c53888c..61e2924 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -3,7 +3,7 @@
// on the GameScreen instance (composed in ../game_screen.js) and only read the
// snapshot and forward orders.
-import { describeNews } from "../modals.js";
+import { describeNews, marketPricesFrom, quotedMoney, quotedBuildCost } from "../modals.js";
import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js";
import {
CIVILISATIONS,
@@ -15,7 +15,7 @@ import {
NEWS_TECHNOLOGY,
NEWS_JOIN,
ECONOMY,
- EFFECT_GDP,
+ EFFECT_PRODUCTION,
APPROVAL,
effectValue,
pillageSteps,
@@ -23,29 +23,22 @@ import {
TRANSPORT_IMPROVEMENTS,
transportImprovement,
TILE_IMPROVEMENTS,
+ RESOURCE_BUILDINGS,
+ resourceById,
+ RESOURCES,
+ RESOURCE_RULES,
+ tileImprovementById,
AIR_STRIKE,
} from "../../../shared/data.js";
-import { defenseFactors, unitDefense, gdpPerCapitaFactors, gdpPerCapitaFromFactors, popularityDisplay } from "../../../shared/rules.js";
+import { defenseFactors, unitDefense, productionFactors, productionMultiplier, popularityDisplay } from "../../../shared/rules.js";
+import { formatEnergy, formatResourceAmount, foodNeedPerDay, protoUpkeep, tileImprovementResourceCost } from "../../../shared/resources.js";
import { dateString, HOURS_PER_DAY, hoursInNextMonth } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
-import { parseKey } from "../../../shared/hex.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { isEconomicMode } from "../map_view/economic.js";
import { isWaterTile } from "../map_view/textures.js";
import { audio } from "../audio.js";
-
-// A tiny memo cache keyed by bucket and key, used while a per-tile economic
-// figure is computed for every tile at once.
-function memo(caches, bucket, key, compute) {
- if (!caches) return compute();
- let cache = caches[bucket];
- if (!cache) {
- cache = new Map();
- caches[bucket] = cache;
- }
- if (!cache.has(key)) cache.set(key, compute());
- return cache.get(key);
-}
+import { currencySymbol } from "../currency.js";
export const panelMethods = {
_bindHud() {
@@ -98,6 +91,8 @@ export const panelMethods = {
this.sendOrder({ type: "policy", policy, ethnicity });
this.nationModal.onRevokePolicy = (policy) =>
this.sendOrder({ type: "revoke_policy", policy });
+ this.nationModal.onSetInterestRate = (rate) =>
+ this.sendOrder({ type: "set_interest_rate", rate });
$("#unit-attack")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
@@ -286,12 +281,12 @@ export const panelMethods = {
// A unit in combat cannot pillage.
if (this._battles.has(`${coords[0]},${coords[1]}`)) return null;
const population = this._population.get(`${coords[0]},${coords[1]}`) || 0;
- const { steps, gdpMultiplier } = pillageSteps(population, units.length);
+ const { steps, productionMultiplier } = pillageSteps(population, units.length);
const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp);
const healed = ordered.map((unit, index) =>
Math.min(unit.maxHp, unit.hp + steps[index].heal) - unit.hp
);
- return { population, steps, gdpMultiplier, healed };
+ return { population, steps, productionMultiplier, healed };
},
_updatePillageButton(units) {
@@ -308,8 +303,8 @@ export const panelMethods = {
.removeClass("hidden")
.attr(
"data-tooltip",
- `Removes ${groupDigits(Math.round(removed))} people (${share.toFixed(0)}%) · ` +
- `GDP/capita ×${forecast.gdpMultiplier.toFixed(2)} · heals ${heals} HP`
+ `Removes ${groupDigits(Math.round(removed))} people (${share.toFixed(0)}%) · ` +
+ `production ×${forecast.productionMultiplier.toFixed(2)} · heals ${heals} HP`
);
},
@@ -408,9 +403,10 @@ export const panelMethods = {
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
if (cost > budget) {
+ const prices = marketPricesFrom(this.snapshot);
return {
title: "Insufficient budget",
- message: `An air strike costs €${groupDigits(cost)}, which the treasury cannot cover.`,
+ message: `An air strike costs ${currencySymbol()}${groupDigits(quotedMoney(cost, prices))}, which the treasury cannot cover.`,
};
}
return null;
@@ -517,9 +513,9 @@ export const panelMethods = {
// stat can show whether it rose or fell since.
const month = ((state.monthly || [])[this.localCiv]) || null;
$("#stat-pop").text(compact(stats.population || 0));
- $("#stat-gdp").text(`€${compact(stats.gdp || 0)}`);
- $("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`);
- $("#stat-budget").text(`€${compact(stats.budget || 0)}`);
+ $("#stat-gdp").text(`${currencySymbol()}${compact(stats.gdp || 0)}`);
+ $("#stat-gdppc").text(`${currencySymbol()}${compact(stats.gdpPerCapita || 0)}`);
+ $("#stat-budget").text(`${currencySymbol()}${compact(stats.budget || 0)}`);
// GDP, population and GDP/capita double the arrow past a 10% month-on-month
// move; the others only ever show one.
this._updateStatArrow("stat-pop", stats.population, month && month.population, 0.1);
@@ -621,7 +617,7 @@ export const panelMethods = {
}
const expected = net * hoursInNextMonth(totalHours || 0);
const sign = expected < 0 ? "−" : "+";
- this._setStatTooltip("stat-budget", `${sign}€${compact(Math.abs(expected))}/mo`);
+ this._setStatTooltip("stat-budget", `${sign}${currencySymbol()}${compact(Math.abs(expected))}/mo`);
},
// The summary's popularity figure: white while it sits in the neutral band
@@ -920,7 +916,10 @@ export const panelMethods = {
$("#tile-desc").text(this._tileDescription(coords, owner, tile, regionCity));
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
this._renderTilePeople(k);
- this._renderTileGdp(owner >= 0 ? this._tileGdpBreakdown(coords, owner, city, tile) : null);
+ this._renderTileProduction(
+ this._tileResourceProduction(coords, owner, city, tile)
+ );
+ this._renderRegionGdp(owner >= 0 ? regionCity : null);
const transport = this.map.railways.has(k)
? ["Railway"]
: this.map.roads.has(k)
@@ -934,7 +933,7 @@ export const panelMethods = {
if (this.map.trenches.has(k)) works.push("Trench");
const militaryWorks = this.map.tileImprovements.get(k);
if (militaryWorks) {
- const proto = TILE_IMPROVEMENTS.find((entry) => entry.id === militaryWorks.id);
+ const proto = tileImprovementById(militaryWorks.id);
works.push(proto ? proto.name : militaryWorks.id);
}
$("#tile-improvements").text(
@@ -968,7 +967,7 @@ export const panelMethods = {
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
);
if (!work) return;
- const proto = TILE_IMPROVEMENTS.find((improvement) => improvement.id === work.id);
+ const proto = tileImprovementById(work.id);
const name = proto ? proto.name : work.id;
const total = Math.max(work.totalHours || 0, 0.0001);
const elapsed = work.elapsedHours || 0;
@@ -1011,7 +1010,8 @@ export const panelMethods = {
// A square icon button for one buildable improvement: the type's icon with
// its cost beneath, and the name (plus upkeep and build time) in the tooltip.
- _improvementButton(improvement, { className, costLabel, title, disabled, current }) {
+ // A `costLabel` override replaces the resource cost (used by the free button).
+ _improvementButton(improvement, { className, costLabel, title, disabled, current, prices } = {}) {
const classes = `aero improvement-btn improvement-icon-btn ${className || ""}`;
const $button = $(``)
.attr("title", title)
@@ -1020,15 +1020,83 @@ export const panelMethods = {
$button.append(
$("")
.attr("src", `assets/${improvement.icon}`)
- .attr("alt", improvement.name),
- $("").text(costLabel)
+ .attr("alt", improvement.name)
);
+ if (costLabel !== undefined) {
+ $button.append($("").text(costLabel));
+ } else {
+ $button.append(this._improvementCost(improvement, prices));
+ }
return $button;
},
- // One square icon button per transport and military improvement, plus a
- // control to remove the one already standing on the tile. Shown only on a
- // controlled land tile; an unaffordable improvement is greyed out.
+ // The cost line under an improvement icon: the steel, electricity and
+ // high-tech it spends, each with its resource icon, then the full price at
+ // today's market (build cost plus the material bill) in parentheses.
+ _improvementCost(improvement, prices) {
+ const costs = tileImprovementResourceCost(improvement);
+ const $cost = $("");
+ const items = [];
+ for (const id of ["steel", "energy", "hightech"]) {
+ if (!(costs[id] > 0)) continue;
+ const proto = resourceById(id);
+ items.push(
+ $("").append(
+ $("")
+ .attr("src", `assets/${proto.icon}`)
+ .attr("alt", proto.name)
+ .attr("title", proto.name),
+ $("").text(formatResourceAmount(id, costs[id]))
+ )
+ );
+ }
+ items.push(
+ $("").text(
+ `(${currencySymbol()}${compact(quotedBuildCost(improvement.buildCost, costs, prices))})`
+ )
+ );
+ items.forEach((item, index) => {
+ if (index > 0) $cost.append(" ");
+ $cost.append(item);
+ });
+ return $cost;
+ },
+
+ // The same cost as plain text, for a tooltip: the resource amounts then the
+ // market price.
+ _improvementCostText(improvement, prices) {
+ const costs = tileImprovementResourceCost(improvement);
+ const parts = [];
+ for (const id of ["steel", "energy", "hightech"]) {
+ if (costs[id] > 0) {
+ parts.push(`${formatResourceAmount(id, costs[id])} ${resourceById(id).name.toLowerCase()}`);
+ }
+ }
+ parts.push(`(${currencySymbol()}${compact(quotedBuildCost(improvement.buildCost, costs, prices))})`);
+ return parts.join(", ");
+ },
+
+ // The daily resource upkeep of a tile work, for its tooltip. Transport wears
+ // per tile rather than as a share of its cost.
+ _improvementUpkeepText(improvement) {
+ if (transportImprovement(improvement.id)) {
+ const steel = RESOURCE_RULES.transportUpkeepSteel[improvement.id] || 0;
+ return `${formatResourceAmount("steel", steel)} steel`;
+ }
+ const upkeep = protoUpkeep(improvement, improvement.buildCost || 0);
+ const parts = [];
+ for (const id of ["steel", "energy", "hightech"]) {
+ if (upkeep[id] > 0) {
+ parts.push(`${formatResourceAmount(id, upkeep[id])} ${resourceById(id).name.toLowerCase()}`);
+ }
+ }
+ return parts.length > 0 ? parts.join(", ") : "no upkeep";
+ },
+
+ // One square icon button per improvement, grouped under a category heading:
+ // military first, then transportation, then economic (resource producers),
+ // plus a control to remove the one already standing on the tile. Shown only
+ // on a controlled land tile; an unaffordable improvement is greyed out.
_renderImprovementActions(coords, buildable, current) {
const $row = $("#tile-improvement-actions");
if (!$row.length) return;
@@ -1036,23 +1104,58 @@ export const panelMethods = {
if (!buildable) return;
const budget = (this.snapshot.viewerStats || {}).budget || 0;
const testing = !!this.testing;
+ const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
+ const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
+ const prices = marketPricesFrom(this.snapshot);
+ const standing = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
+ const standingProto = standing ? tileImprovementById(standing.id) : null;
+ const isResource = !!(standingProto && standingProto.resource);
+
+ // Military tile works. They cost resources and take production time, so the
+ // tooltip names the expected hours alongside the upkeep.
+ const military = [];
+ for (const improvement of TILE_IMPROVEMENTS) {
+ if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
+ const built = !!(standing && standing.id === improvement.id);
+ const affordable = budget >= improvement.buildCost;
+ const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
+ const title = standing && !built
+ ? `${improvement.name} — another improvement already stands here`
+ : affordable
+ ? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
+ `${this._improvementUpkeepText(improvement)}/day · ${hours(buildHours)} to build`
+ : `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
+ const $button = this._improvementButton(improvement, {
+ className: "tile-btn",
+ title,
+ disabled: !!standing || !affordable,
+ current: built,
+ prices,
+ });
+ $button.on("click", () => this._buildTileImprovement(coords, improvement.id));
+ military.push($button);
+ }
+
+ // Transportation: roads and railways, bought outright and laid at once.
+ const transport = [];
for (const improvement of TRANSPORT_IMPROVEMENTS) {
const isCurrent = current === improvement.id;
const affordable = budget >= improvement.buildCost;
const title = isCurrent
? `${improvement.name} — already built on this tile`
: affordable
- ? `${improvement.name} — €${groupDigits(improvement.maintenance)}/tile·h upkeep`
- : `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
+ ? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
+ `${this._improvementUpkeepText(improvement)}/day upkeep`
+ : `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
const $button = this._improvementButton(improvement, {
className: "transport-btn",
- costLabel: `€${compact(improvement.buildCost)}`,
title,
disabled: isCurrent || !affordable,
current: isCurrent,
+ prices,
});
$button.on("click", () => this._buildImprovement(coords, improvement.id, false));
- $row.append($button);
+ transport.push($button);
// A local test game may raise it for free and at once.
if (testing && !isCurrent) {
const $free = this._improvementButton(improvement, {
@@ -1063,48 +1166,78 @@ export const panelMethods = {
current: false,
});
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
- $row.append($free);
+ transport.push($free);
}
}
if (current) {
- const name = transportImprovement(current) ? transportImprovement(current).name : current;
- const $remove = $("")
- .text(`Remove ${name}`)
- .on("click", () => this._removeImprovement(coords));
- $row.append($remove);
+ const proto = transportImprovement(current);
+ const name = proto ? proto.name : current;
+ transport.push(
+ $("")
+ .text(`Remove ${name}`)
+ .on("click", () => this._removeImprovement(coords))
+ );
}
- // Military tile improvements. They cost money and take production time, so
- // the tooltip names the expected hours alongside the upkeep.
- const military = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
- const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
- const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
- for (const improvement of TILE_IMPROVEMENTS) {
- if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
- const built = !!(military && military.id === improvement.id);
- const affordable = budget >= improvement.buildCost;
- const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
- const title = military && !built
- ? `${improvement.name} — another improvement already stands here`
- : affordable
- ? `${improvement.name} — €${groupDigits(improvement.maintenance)}/h upkeep · ${hours(buildHours)} to build`
- : `${improvement.name} — not enough budget (€${groupDigits(improvement.buildCost)} needed)`;
- const $button = this._improvementButton(improvement, {
- className: "tile-btn",
- costLabel: `€${compact(improvement.buildCost)}`,
- title,
- disabled: !!military || !affordable,
- current: built,
- });
- $button.on("click", () => this._buildTileImprovement(coords, improvement.id));
- $row.append($button);
+ // Economic: resource producers. They live on their own land, take
+ // production time and feed the delivery graph; only one may stand on a
+ // tile.
+ const economic = [];
+ if (!isResource) {
+ for (const producer of RESOURCE_BUILDINGS) {
+ if (producer.coastal && !this._tileIsCoastal(coords)) continue;
+ const built = !!(standing && standing.id === producer.id);
+ const affordable = budget >= producer.buildCost;
+ const buildHours = ECONOMY.trainingHours(gdp, producer.buildCost) / (1 + speed);
+ // Some producers are locked behind a technology (the fusion plant).
+ const techIndex = producer.requiresTechnology
+ ? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology)
+ : -1;
+ const locked = techIndex >= 0 && !this._researched.has(techIndex);
+ const out = producer.resource === "energy"
+ ? formatEnergy(producer.outputPerDay)
+ : `${compact(producer.outputPerDay)} ${producer.resource}/day`;
+ const title = locked
+ ? `${producer.name} — requires ${TECHNOLOGIES[techIndex].name}`
+ : standing && !built
+ ? `${producer.name} — another building already stands here`
+ : affordable
+ ? `${producer.name} — ${out} · ${this._improvementCostText(producer, prices)} · ` +
+ `${hours(buildHours)} to build`
+ : `${producer.name} — needs ${this._improvementCostText(producer, prices)}`;
+ const $button = this._improvementButton(producer, {
+ className: "tile-btn resource-btn",
+ title,
+ disabled: !!standing || !affordable || locked,
+ current: built,
+ prices,
+ });
+ $button.on("click", () => this._buildTileImprovement(coords, producer.id));
+ economic.push($button);
+ }
}
- if (military) {
- const name = (TILE_IMPROVEMENTS.find((i) => i.id === military.id) || {}).name || military.id;
+ if (standing) {
+ const name = standingProto ? standingProto.name : standing.id;
const $remove = $("")
.text(`Remove ${name}`)
.on("click", () => this._removeTileImprovement(coords));
- $row.append($remove);
+ // A standing work is removed from its own category.
+ if (isResource) economic.push($remove);
+ else military.push($remove);
+ }
+
+ for (const group of [
+ { label: "Military", buttons: military },
+ { label: "Transportation", buttons: transport },
+ { label: "Economic", buttons: economic },
+ ]) {
+ if (group.buttons.length === 0) continue;
+ const $group = $("");
+ $group.append($("").text(group.label));
+ const $buttons = $("");
+ for (const $button of group.buttons) $buttons.append($button);
+ $group.append($buttons);
+ $row.append($group);
}
},
@@ -1216,66 +1349,66 @@ export const panelMethods = {
$container.append(ethnicityChart(makeup, this.civilisations, 104));
},
- // The base GDP per capita of a claimable tile plus every factor that shapes
- // it, mirroring the server's getTileGdpPerCapita through the shared helper.
- // The region that develops the tile supplies the building bonus; government
- // and research supply the nation-wide one.
- _tileGdpBreakdown(coords, owner, city, tile, caches = null) {
+ // The resources a tile produces in a day, each amount already carrying the
+ // tile's production modifiers, plus the factor list behind it. Food comes from
+ // the people on the tile; every other resource from the work standing there.
+ _tileResourceProduction(coords, owner, city, tile) {
const k = `${coords.x},${coords.y}`;
const regionCity =
city || (this.snapshot.cities || []).find((c) => c.id === this._tileRegion.get(k));
- const regionalModifier = regionCity
- ? memo(caches, "regional", regionCity.id, () => this._cityGdpModifier(regionCity))
- : 0;
- const nationalModifier = memo(caches, "national", owner, () => this._nationalGdpModifier(owner));
- const factors = gdpPerCapitaFactors(tile, {
- cityKind: city ? (city.isCapital ? "capital" : "city") : null,
- nextToCity: !city && memo(caches, "nextToCity", k, () => this._isNextToCity(coords, owner)),
- pillagePenalty: this._tileGdpPenalties.has(k) ? this._tileGdpPenalties.get(k) : 1,
- battleDeficit: this._tileBattleGdpDeficits.get(k) || 0,
- regionalMultiplier: 1 + regionalModifier,
- regionalLabel: regionCity ? regionCity.name : null,
- nationalMultiplier: 1 + nationalModifier,
- portGdp: regionCity
- ? memo(caches, "portGdp", regionCity.id, () => this._regionPortGdp(regionCity))
- : 0,
- naturalGrowth: memo(caches, "growth", k, () => this._naturalTileGdpGrowth(k)),
- });
- return {
- base: ECONOMY.baseGdpPerCapita,
- factors,
- total: gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors),
- };
+ const factors = this._productionFactors(coords, owner, city, tile, regionCity);
+ const multiplier = productionMultiplier(factors);
+ const amounts = { energy: 0, steel: 0, food: 0, luxury: 0, hightech: 0 };
+ const population = this._population.get(k) || 0;
+ let foodMultiplier = 1;
+ if (population > 0 && tile) {
+ foodMultiplier = regionCity ? this._regionFoodMultiplier(regionCity) : 1;
+ const factor = RESOURCE_RULES.tileFoodFactor[tile.terrainType] ?? 1;
+ amounts.food = foodNeedPerDay(population) * factor * foodMultiplier * multiplier;
+ }
+ const improvement = this.map.tileImprovements.get(k);
+ const proto = improvement ? tileImprovementById(improvement.id) : null;
+ if (proto && proto.resource && proto.outputPerDay) {
+ amounts[proto.resource] += proto.outputPerDay * multiplier;
+ }
+ return { amounts, factors, foodMultiplier };
},
- // The flat GDP per capita a region's ports add to every tile it covers,
- // mirroring the server: 1000 per port level for each sea tile beside the city.
- _regionPortGdp(city) {
+ // The production modifiers of one tile: terrain, settlement, war and the
+ // region's and nation's own bonuses. Mirrors the server's `productionFactors`
+ // inputs so the tile panel and the simulation explain the same figure.
+ _productionFactors(coords, owner, city, tile, regionCity) {
+ const k = `${coords.x},${coords.y}`;
+ return productionFactors(tile, {
+ cityKind: city ? (city.isCapital ? "capital" : "city") : null,
+ nextToCity: !city && this._isNextToCity(coords, owner),
+ pillagePenalty: this._tileProductionPenalties.has(k) ? this._tileProductionPenalties.get(k) : 1,
+ battleDeficit: this._tileBattleProductionDeficits.get(k) || 0,
+ trench: this.map.trenches.has(k),
+ regionalMultiplier: 1 + (regionCity ? this._cityProductionModifier(regionCity) : 0),
+ regionalLabel: regionCity ? regionCity.name : null,
+ nationalMultiplier: 1 + this._nationalProductionModifier(owner),
+ });
+ },
+
+ // The extra food a region's ports bring in, mirroring the server: 5% per
+ // adjacent sea tile per port level.
+ _regionFoodMultiplier(city) {
const protos = this.snapshot.buildings || [];
let bonus = 0;
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = protos[index];
- if (proto && proto.mechanic === "port_gdp") {
- bonus += (proto.gdpPerCapitaPerSeaTilePerLevel || 0) * level;
+ if (proto && proto.mechanic === "port_production") {
+ bonus += (proto.foodPerSeaTilePerLevel || 0) * level;
}
}
- if (bonus === 0) return 0;
+ if (bonus === 0) return 1;
let sea = 0;
for (const neighbour of this.map.topology.neighbours(city.coords[0], city.coords[1])) {
const tile = this.map.getTile(neighbour);
if (tile && tile.terrainClass === "Sea") sea += 1;
}
- return bonus * sea;
- },
-
- // The flat GDP per capita a tile has gained since January 2000, mirroring the
- // server: its opening figure times the daily rate, once per day elapsed.
- _naturalTileGdpGrowth(k) {
- const baseline = this._gdpBaseline.get(k) || 0;
- if (!baseline) return 0;
- const totalHours = (this.snapshot && this.snapshot.totalHours) || 0;
- const days = Math.floor(Math.max(0, totalHours) / HOURS_PER_DAY);
- return baseline * ECONOMY.naturalGdpGrowthPerDay * days;
+ return 1 + bonus * sea;
},
// -------------------------------------------------------- map modes -----
@@ -1301,16 +1434,21 @@ export const panelMethods = {
},
// One figure per land tile for an economic mode, with the range the legend
- // and the colour scale need. GDP per capita mirrors the tile panel's formula;
- // population is the snapshot's raw per-tile count.
+ // and the colour scale need. GDP per capita is the region's, so every tile of
+ // a region shares one colour; population is the raw per-tile count.
_computeEconomicValues(mode) {
const values = new Map();
let min = Infinity;
let max = -Infinity;
if (!this.map || !this.map.tiles) return { values, min: 0, max: 0 };
- const cities = this.snapshot.cities || [];
- const cityByKey = new Map(cities.map((c) => [`${c.coords[0]},${c.coords[1]}`, c]));
- const caches = mode === "gdp" ? {} : null;
+ const gdpPerCapita = new Map();
+ if (mode === "gdp") {
+ for (const city of this.snapshot.cities || []) {
+ const stats = this._cityStats(city.id);
+ const population = stats.population || 0;
+ gdpPerCapita.set(city.id, population > 0 ? (stats.gdp || 0) / population : 0);
+ }
+ }
for (const k in this.map.tiles) {
const tile = this.map.tiles[k];
if (isWaterTile(tile)) continue;
@@ -1318,10 +1456,7 @@ export const panelMethods = {
if (mode === "population") {
value = this._population.get(k) || 0;
} else {
- const coords = parseKey(k);
- const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
- const city = cityByKey.get(k) || null;
- value = this._tileGdpBreakdown(coords, owner, city, tile, caches).total;
+ value = gdpPerCapita.get(this._tileRegion.get(k)) || 0;
}
values.set(k, value);
if (value < min) min = value;
@@ -1331,34 +1466,35 @@ export const panelMethods = {
return { values, min, max };
},
- // The GDP bonus a single city's own buildings give.
- _cityGdpModifier(city) {
+ // The production bonus a single city's own buildings give.
+ _cityProductionModifier(city) {
let total = 0;
for (const indexStr of Object.keys(city.buildings || {})) {
const proto = BUILDINGS[Number(indexStr)];
if (!proto) continue;
const level = city.buildings[indexStr];
for (const e of proto.effects) {
- if (e.stat === EFFECT_GDP) total += effectValue(e, level);
+ if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, level);
}
}
return total;
},
- // The GDP bonus a nation gets from its government and researched technology.
- _nationalGdpModifier(civ) {
+ // The production bonus a nation gets from its government and researched
+ // technology.
+ _nationalProductionModifier(civ) {
let total = 0;
const government = GOVERNMENTS[(this.snapshot.government || [])[civ]];
if (government) {
for (const e of government.effects) {
- if (e.stat === EFFECT_GDP) total += effectValue(e, 1);
+ if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, 1);
}
}
for (const index of (this.snapshot.researched || [])[civ] || []) {
const tech = TECHNOLOGIES[index];
if (!tech) continue;
for (const e of tech.effects) {
- if (e.stat === EFFECT_GDP) total += effectValue(e, 1);
+ if (e.stat === EFFECT_PRODUCTION) total += effectValue(e, 1);
}
}
return total;
@@ -1377,53 +1513,64 @@ export const panelMethods = {
return false;
},
- // Lists the tile's GDP per capita and each factor behind it, so a click
- // explains exactly why the figure is what it is.
- _renderTileGdp(breakdown) {
- const $container = $("#tile-gdp").empty();
- if (!breakdown) {
- $container.text("GDP/capita: —");
+ // The tile panel's headline: what the tile actually produces, one row per
+ // resource (nothing is shown for a resource the tile does not make), with the
+ // modifiers that shape it in a hover tooltip.
+ _renderTileProduction(production) {
+ const $container = $("#tile-production").empty();
+ const rows = [];
+ for (const resource of RESOURCES) {
+ const amount = production.amounts[resource.id] || 0;
+ if (!(amount > 0)) continue;
+ const notes = [];
+ for (const factor of production.factors) {
+ if (factor.multiplier !== undefined) {
+ if (factor.multiplier === 1) continue;
+ notes.push(`${factor.label}: ×${factor.multiplier.toFixed(2)}`);
+ } else if (factor.amount) {
+ const sign = factor.amount < 0 ? "−" : "+";
+ notes.push(`${factor.label}: ${sign}${currencySymbol()}${groupDigits(Math.round(Math.abs(factor.amount)))}`);
+ }
+ }
+ if (resource.id === "food" && production.foodMultiplier !== 1) {
+ notes.push(`Port food: ×${production.foodMultiplier.toFixed(2)}`);
+ }
+ const $row = $("");
+ $row.append(
+ $("")
+ .attr("src", `assets/${resource.icon}`)
+ .attr("alt", resource.name),
+ $("").text(resource.name),
+ $("").text(
+ `${formatResourceAmount(resource.id, amount)}/day`
+ )
+ );
+ if (notes.length > 0) $row.attr("title", notes.join("\n"));
+ rows.push($row);
+ }
+ if (rows.length === 0) {
+ $container.append($("").text("No production"));
return;
}
- const $mods = $("");
- if (!this._gdpExpanded) $mods.addClass("hidden");
- $mods.append(
- $("").text(`Base €${groupDigits(breakdown.base)}`)
- );
- let modifierCount = 0;
- for (const factor of breakdown.factors) {
- // A modifier that changes nothing (×1 or ±€0) is left out.
- if (factor.amount !== undefined) {
- if (factor.amount === 0) continue;
- } else if (factor.multiplier === 1) {
- continue;
- }
- const value =
- factor.amount !== undefined
- ? `${factor.amount < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(factor.amount)))}`
- : `×${factor.multiplier.toFixed(2)}`;
- $mods.append(
- $("").text(`${factor.label} ${value}`)
- );
- modifierCount += 1;
+ for (const $row of rows) $container.append($row);
+ },
+
+ // The only place GDP per capita is shown: the region the tile belongs to. A
+ // tile never gets a per-tile figure of its own.
+ _renderRegionGdp(regionCity) {
+ const $container = $("#tile-gdp").empty();
+ if (!regionCity) {
+ $container.text("Region GDP/capita: —");
+ return;
}
- const $head = $("").text(
- `GDP/capita: €${groupDigits(Math.round(breakdown.total))}`
+ const stats = this._cityStats(regionCity.id);
+ const population = stats.population || 0;
+ const perCapita = population > 0 ? (stats.gdp || 0) / population : 0;
+ $container.append(
+ $("").text(
+ `Region GDP/capita: ${currencySymbol()}${groupDigits(Math.round(perCapita))}`
+ )
);
- if (modifierCount > 0) {
- $head.addClass("tile-gdp-toggle");
- $head.append(
- $("").text(this._gdpExpanded ? "▾" : "▸")
- );
- $head.on("click", () => {
- this._gdpExpanded = !this._gdpExpanded;
- $mods.toggleClass("hidden", !this._gdpExpanded);
- $head.find(".tile-gdp-caret").text(this._gdpExpanded ? "▾" : "▸");
- });
- $container.append($head, $mods);
- } else {
- $container.append($head);
- }
},
// The tile/city panel and the unit panel are independent docks, so either
@@ -1484,12 +1631,19 @@ export const panelMethods = {
queue: this._trainingQueue(city.id),
civs: this.civilisations,
ethnicMakeup: cityStats.ethnicMakeup || [],
+ // The region's private-sector cash basket and the currencies it names.
+ cash: cityStats.cash || {},
+ cashValue: cityStats.cashValue || 0,
+ debt: cityStats.debt || 0,
+ currencies: stats.currencies || [],
approval: cityStats.approval,
// The region's tax base and approval price an upgrade's expected income.
base: cityStats.base || 0,
// Construction technology shortens the displayed build times.
constructionSpeed: stats.constructionSpeed || 0,
taxMultiplier: this._taxMultiplier(cityStats.approval),
+ // Today's market prices, so build costs are quoted at current rates.
+ prices: marketPricesFrom(this.snapshot),
};
},
@@ -1509,6 +1663,7 @@ export const panelMethods = {
this._updateCityProgress(queue);
const cityStats = this._cityStats(this.selectedCityId);
this.cityModal.constructionSpeed = stats.constructionSpeed || 0;
+ this.cityModal.prices = marketPricesFrom(this.snapshot);
const city = this._city(this.selectedCityId);
if (city) {
this.cityModal.refreshTrainList(this._trainableUnits(city));
@@ -1527,6 +1682,12 @@ export const panelMethods = {
{ base: cityStats.base || 0, approval: cityStats.approval }
);
this.cityModal.renderBudget(this._cityBudget(this.selectedCityId));
+ this.cityModal.renderEconomy(
+ cityStats.cash || {},
+ cityStats.cashValue || 0,
+ stats.currencies || [],
+ cityStats.debt || 0
+ );
},
_updateCityProgress(queue) {
@@ -1595,10 +1756,40 @@ export const panelMethods = {
this._diplomacyInfo(),
this._conflicts,
this._politicsInfo(),
- this._techInfo()
+ this._techInfo(),
+ this._resourcesInfo(),
+ this._moneyInfo()
);
},
+ // The money the Economy tab needs: the viewer's currency, everyone else's,
+ // the exchange rates, the central bank and the aggregate private-sector cash.
+ _moneyInfo() {
+ const stats = (this.snapshot && this.snapshot.viewerStats) || {};
+ return {
+ viewerCiv: this.localCiv,
+ currency: stats.currency || null,
+ currencies: stats.currencies || [],
+ exchangeRates: stats.exchangeRates || [],
+ centralBank: stats.centralBank || { rate: 0, reserves: [] },
+ privateCash: stats.privateCash || 0,
+ cities: (this.snapshot && this.snapshot.cityStats) || [],
+ };
+ },
+
+ // Everything the Resources tab needs: the nation's own stores, needs and
+ // trade from the viewer stats, plus the world market's prices, the world's
+ // supply and demand, and every nation's import/export ledger.
+ _resourcesInfo() {
+ const stats = (this.snapshot && this.snapshot.viewerStats) || {};
+ return {
+ summary: stats.resources || null,
+ market: (this.snapshot && this.snapshot.resourceMarket) || [],
+ trade: (this.snapshot && this.snapshot.resourceTrade) || [],
+ civs: this.civilisations,
+ };
+ },
+
// The focused-research figures the technology tab reads from the snapshot.
_techInfo() {
const snapshot = this.snapshot || {};
@@ -1628,7 +1819,9 @@ export const panelMethods = {
this._diplomacyInfo(),
this._conflicts,
this._politicsInfo(),
- this._techInfo()
+ this._techInfo(),
+ this._resourcesInfo(),
+ this._moneyInfo()
);
},
@@ -1703,11 +1896,12 @@ export const panelMethods = {
},
_finances(stats) {
- if (!stats || !stats.breakdown) return null;
- // The region figures are global, so the Cities tab can show every nation's;
- // the panel filters them to the viewer.
+ if (!stats) return null;
+ // The month reports page back through the stored budget digests; the region
+ // figures are global, so the Cities tab can show every nation's, and the
+ // panel filters them to the viewer.
return {
- ...stats.breakdown,
+ months: stats.budgetMonths || [],
cities: (this.snapshot && this.snapshot.cityStats) || [],
};
},
diff --git a/client/js/map_view.js b/client/js/map_view.js
index e2c17a4..5f56061 100644
--- a/client/js/map_view.js
+++ b/client/js/map_view.js
@@ -47,6 +47,17 @@ export class MapView {
this.$targets = layers.targets && layers.targets.length
? layers.targets
: $("#layer-targets");
+ // Resource storage nodes and delivery legs, drawn in the economic map
+ // modes so the delivery graph is never visible on the political map.
+ this.$resources = layers.resources && layers.resources.length
+ ? layers.resources
+ : $("#layer-resources");
+ if (!this.$resources.length) {
+ this.$resources = $('');
+ if (this.$targets && this.$targets.length) this.$targets.after(this.$resources);
+ else this.$world.append(this.$resources);
+ }
+ this.resourceGraph = { nodes: [], links: [] };
this.$entities = layers.entities;
// City names live on their own layer above the entities so they are never
// hidden behind a unit or city marker.
@@ -78,7 +89,7 @@ export class MapView {
this.$viewport[0].style.setProperty("--camera-tilt", String(CAMERA_TILT));
for (const $layer of [
this.$terrain, this.$roads, this.$borders, this.$fog,
- this.$highlight, this.$paths, this.$targets,
+ this.$highlight, this.$paths, this.$targets, this.$resources,
]) {
if ($layer && $layer.length) $layer.addClass("tilted");
}
diff --git a/client/js/map_view/economic.js b/client/js/map_view/economic.js
index dfd4208..cdc1145 100644
--- a/client/js/map_view/economic.js
+++ b/client/js/map_view/economic.js
@@ -10,6 +10,7 @@
import { parseColour } from "./utils.js";
import { groupDigits } from "../../../shared/text_format.js";
+import { currencySymbol } from "../currency.js";
export const ECONOMIC_GRADIENT = [
"#ffea46",
@@ -31,7 +32,7 @@ export const ECONOMIC_MODES = {
gdp: {
id: "gdp",
label: "GDP per capita",
- format: (value) => `€${groupDigits(Math.round(value))}`,
+ format: (value) => `${currencySymbol()}${groupDigits(Math.round(value))}`,
},
population: {
id: "population",
diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js
index 533b569..7363580 100644
--- a/client/js/map_view/modes.js
+++ b/client/js/map_view/modes.js
@@ -4,11 +4,13 @@
// and drives the economic fill and its legend. The political names themselves
// live in ./political.js; this file only decides what is on screen.
-import { HEX_W } from "../../../shared/hex.js";
+import { HEX_W, mapToLocal } from "../../../shared/hex.js";
import { POLITICAL_ZOOM } from "./constants.js";
import { ECONOMIC_GRADIENT, ECONOMIC_MODES, economicColour, isEconomicMode } from "./economic.js";
import { NEUTRAL_LAND, POLITICAL_WATER } from "./political.js";
import { isWaterTile } from "./textures.js";
+import { makeSegment } from "./utils.js";
+import { RESOURCES } from "../../../shared/data/resources.js";
export const MAP_MODES = ["terrain", "political", "gdp", "population"];
@@ -45,6 +47,47 @@ export const modeMethods = {
return `${this.economicKind}:${this.economicValues.size}:${Math.round(sum)}`;
},
+ // ------------------------------------------------- delivery graph ------
+
+ // Replaces the delivery graph shipped in the snapshot. It is only drawn in
+ // the economic modes, so a political or terrain map never reveals it.
+ setResourceGraph(graph) {
+ if (!graph) return;
+ this.resourceGraph = {
+ nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
+ links: Array.isArray(graph.links) ? graph.links : [],
+ };
+ this._renderResourceGraph();
+ },
+
+ // Draws one line per delivery leg and a dot on every storage node. The layer
+ // shares the world's coordinate system, so pan and zoom move it for free.
+ _renderResourceGraph() {
+ if (!this.$resources || !this.$resources.length) return;
+ const graph = this.resourceGraph || { nodes: [], links: [] };
+ if (!this.economic || graph.links.length === 0) {
+ if (this.$resources[0].firstChild) this.$resources.empty();
+ return;
+ }
+ const colours = Object.fromEntries(RESOURCES.map((r) => [r.id, r.colour]));
+ const fragment = document.createDocumentFragment();
+ for (const link of graph.links) {
+ const [fromX, fromY, toX, toY, resource] = link;
+ const a = mapToLocal(fromX, fromY);
+ const b = mapToLocal(toX, toY);
+ fragment.appendChild(makeSegment(a, b, colours[resource] || "#ffffff", 3));
+ }
+ for (const node of graph.nodes) {
+ const point = mapToLocal(node[0], node[1]);
+ const dot = document.createElement("div");
+ dot.className = "resource-node";
+ dot.style.left = `${point.x}px`;
+ dot.style.top = `${point.y}px`;
+ fragment.appendChild(dot);
+ }
+ this.$resources.empty()[0].appendChild(fragment);
+ },
+
// Political shows when the user asked for it, or while terrain mode is
// zoomed far enough out. Economic modes are exclusive with it.
_effectivePolitical() {
@@ -109,6 +152,7 @@ export const modeMethods = {
}
if (this.political) this._renderPoliticalLabels();
else if (this.$politicalLabels) this.$politicalLabels.empty();
+ this._renderResourceGraph();
this._updateLegend();
},
diff --git a/client/js/modals.js b/client/js/modals.js
index 033fc8c..5240526 100644
--- a/client/js/modals.js
+++ b/client/js/modals.js
@@ -12,4 +12,4 @@ export { ConfirmModal } from "./modals/confirm.js";
export { NewsModal } from "./modals/news.js";
export { MapModesModal } from "./modals/map_modes.js";
export { CapabilityModal } from "./modals/capabilities.js";
-export { describeNews } from "./modals/format.js";
+export { describeNews, marketPricesFrom, quotedMoney, quotedBuildCost } from "./modals/format.js";
diff --git a/client/js/modals/budget.js b/client/js/modals/budget.js
index 171f49a..3671c41 100644
--- a/client/js/modals/budget.js
+++ b/client/js/modals/budget.js
@@ -4,7 +4,10 @@
// per-city cost. `breakdown` mirrors the shape both the national and the
// regional economy use, so one renderer serves them both.
-import { percent, signedMoney, SOURCE_LABELS } from "./format.js";
+import { percent, signedMoney, money, SOURCE_LABELS } from "./format.js";
+import { BUDGET_EXPENSE_CATEGORIES, resourceById } from "../../../shared/data.js";
+import { groupDigits } from "../../../shared/text_format.js";
+import { currencySymbol } from "../currency.js";
// Draws the table into `$list`. `expanded` is the set of open building types,
// mutated in place; `onToggle` is called after a caret flips so the caller can
@@ -21,6 +24,7 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
const campaignSources = campaigns.sources || [];
const transport = breakdown.transportUpkeep || { total: 0, sources: [] };
const transportSources = transport.sources || [];
+ const resourceMoney = breakdown.resourceMoney || [];
const net = breakdown.net || 0;
$list.empty();
@@ -95,12 +99,132 @@ export function renderBudget($list, breakdown = {}, { expanded = new Set(), onTo
}
}
+ // What the nation spent buying each resource, and earned selling it to other
+ // countries, over the last day, shown as an hourly average.
+ if (resourceMoney.length > 0) {
+ $body.append(sectionRow("Resource trade"));
+ for (const entry of resourceMoney) {
+ const proto = resourceById(entry.id);
+ const name = proto ? proto.name : entry.id;
+ if (entry.bought > 0) {
+ $body.append(detailRow(`Bought ${name}`, -entry.bought / 24, "expense-detail"));
+ }
+ if (entry.sold > 0) {
+ $body.append(detailRow(`Sold ${name}`, entry.sold / 24, "income-detail"));
+ }
+ }
+ }
+
const $table = $("
");
$table.append($body);
$table.append($("").append(totalRow("TOTAL", net)));
$list.append($table);
}
+// Renders a whole month's actual budget: the tax and resource sales earned, the
+// money each expense category spent, and the resource-trade memo. `report` is
+// the compact shape the snapshot ships (category ids, not labels). The cash
+// spent is the headline; when a category consumed materials worth more than the
+// cash it paid (the stores covered the rest), the row notes the full market
+// value. The treasury itself lives on the panel's subtitle, not in the table.
+export function renderMonthlyBudget($list, report, { expanded = new Set(), onToggle = null } = {}) {
+ if (!$list || !$list.length) return;
+ $list.empty();
+ if (!report) return;
+ const $body = $("");
+
+ $body.append(sectionRow("Income"));
+ $body.append(monthRow("Tax income", report.income, "income", null));
+ if (report.sales > 0) {
+ $body.append(monthRow("Resource sales", report.sales, "income-detail", null));
+ }
+
+ $body.append(sectionRow("Expenses"));
+ const byId = new Map((report.expenses || []).map((entry) => [entry[0], entry]));
+ const sections = [];
+ for (const category of BUDGET_EXPENSE_CATEGORIES) {
+ const entry = byId.get(category.id);
+ if (!entry) continue;
+ const last = sections[sections.length - 1];
+ if (!last || last.group !== category.group) {
+ sections.push({ group: category.group, rows: [] });
+ }
+ sections[sections.length - 1].rows.push({
+ id: category.id,
+ label: category.label,
+ cash: entry[1],
+ economic: entry[2],
+ buys: entry[3] || [],
+ });
+ }
+ for (const section of sections) {
+ $body.append(subsectionRow(section.group));
+ for (const row of section.rows) {
+ const note = row.economic > row.cash + 1
+ ? `materials valued at ${currencySymbol()}${groupDigits(Math.round(row.economic))}`
+ : null;
+ const isOpen = expanded.has(row.id);
+ const expandable = row.buys.length > 0;
+ const $tr = monthRow(row.label, -row.cash, "expense-detail", note);
+ if (expandable) {
+ const $name = $tr.find(".budget-name");
+ $name.addClass("clickable");
+ $name.prepend($("").text(isOpen ? "▾" : "▸"));
+ $name.on("click", () => {
+ if (expanded.has(row.id)) expanded.delete(row.id);
+ else expanded.add(row.id);
+ if (onToggle) onToggle(row.id);
+ });
+ }
+ $body.append($tr);
+ if (isOpen) {
+ for (const [resource, amount] of row.buys) {
+ const proto = resourceById(resource);
+ const name = proto ? proto.name : resource;
+ $body.append(monthRow(` ${name}`, -amount, "expense-detail", null));
+ }
+ }
+ }
+ }
+
+ const trade = report.resourceTrade || [];
+ if (trade.length > 0) {
+ $body.append(sectionRow("Resource trade"));
+ for (const [id, bought, sold] of trade) {
+ const proto = resourceById(id);
+ const name = proto ? proto.name : id;
+ if (bought > 0) $body.append(monthRow(`Bought ${name}`, -bought, "expense-detail", null));
+ if (sold > 0) $body.append(monthRow(`Sold ${name}`, sold, "income-detail", null));
+ }
+ }
+
+ const $table = $("
").text(`+${currencySymbol()}${compact(totalIncome)}`))
)
);
this.$citiesList.append($table);
}
+ // The Resources tab: the nation's stores, what it consumes and makes, what it
+ // imports and exports, beside the global market's price and the world's supply
+ // and demand. Energy is a daily flow, so it has no store: its row reads the
+ // cities' demand against the power plants' output instead.
+ _renderResources(info) {
+ if (!this.$resourcesList || !this.$resourcesList.length) return;
+ this.$resourcesList.empty();
+ const summary = info && info.summary;
+ if (this.$resourcesSub && this.$resourcesSub.length) {
+ this.$resourcesSub.text(
+ "Stores, daily consumption, production and cross-nation trade, with the " +
+ "global market's price. Energy is delivered as a flow and cannot be stored."
+ );
+ }
+ if (!summary) {
+ this.$resourcesList.append($("").text("No resource data."));
+ } else {
+ this._renderResourceStores(info, summary);
+ }
+ this._renderResourceLeaders(info);
+ this._setResourceView(this.resourceView);
+ }
+
+ _renderResourceStores(info, summary) {
+ const market = new Map(((info && info.market) || []).map((entry) => [entry.id, entry]));
+ const trade = summary.trade || { imports: {}, exports: {} };
+ const $table = $("
").text(
+ formatResourceAmount(proto.id, Math.max(0, stats.demand || 0))
+ )
+ );
+ $body.append($tr);
+ }
+ $table.append($head, $body);
+ this.$resourcesList.append($table);
+ this.$resourcesList.append(
+ $("").text(
+ "Consumption is the people's needs plus the materials buildings, units and works " +
+ "wear out. Exports and imports count yesterday's deliveries between nations; the " +
+ "global market is not a partner. Prices drift with the world's supply and demand, " +
+ "and a city buys from the cheapest source it can reach, then from the market."
+ )
+ );
+ }
+
+ // The Major traders sub-tab: for every commodity, the nation that exported the
+ // most over the last day and the one that imported the most.
+ _renderResourceLeaders(info) {
+ if (!this.$resourceTradeList || !this.$resourceTradeList.length) return;
+ this.$resourceTradeList.empty();
+ const trade = (info && info.trade) || [];
+ const civs = (info && info.civs) || [];
+ const name = (civ) => (civs[civ] ? civs[civ].name : `Nation ${civ + 1}`);
+ const $table = $("
");
@@ -604,7 +1104,7 @@ export class NationModal {
const spent = hourly * activeHours;
const days = Math.floor(activeHours / 24);
return (
- `€${groupDigits(hourly)}/h · €${groupDigits(Math.round(spent))} spent over ` +
+ `${currencySymbol()}${groupDigits(this._quoted(hourly))}/h · ${currencySymbol()}${groupDigits(Math.round(spent))} spent over ` +
`${days} day${days === 1 ? "" : "s"}`
);
}
@@ -632,10 +1132,10 @@ export class NationModal {
value: campaign.hourlyCost,
});
const $spend = $("").text(
- `€${groupDigits(campaign.hourlyCost)}/h`
+ `${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`
);
$slider.on("input", () =>
- $spend.text(`€${groupDigits(Number($slider.val()))}/h`)
+ $spend.text(`${currencySymbol()}${groupDigits(this._quoted(Number($slider.val())))}/h`)
);
$slider.on("change", () => {
const amount = Number($slider.val());
@@ -643,10 +1143,10 @@ export class NationModal {
// Reverting to the last known spend keeps the control honest until
// the player confirms the stop.
$slider.val(campaign.hourlyCost);
- $spend.text(`€${groupDigits(campaign.hourlyCost)}/h`);
+ $spend.text(`${currencySymbol()}${groupDigits(this._quoted(campaign.hourlyCost))}/h`);
this.onConfirm(
"Stop this campaign?",
- "Cutting the spending to €0 will stop the campaign.",
+ `Cutting the spending to ${currencySymbol()}0 will stop the campaign.`,
() => this.onCancelCampaign(campaign.id)
);
} else {
@@ -683,7 +1183,7 @@ export class NationModal {
);
$card.append(
$("").text(
- `Costs €${groupDigits(PROPAGANDA.defaultHourlyCost)}/h to begin; ` +
+ `Costs ${currencySymbol()}${groupDigits(this._quoted(PROPAGANDA.defaultHourlyCost))}/h to begin; ` +
"adjust the spending on an active campaign to make it land harder or softer."
)
);
diff --git a/scripts/simulate_resources.js b/scripts/simulate_resources.js
new file mode 100644
index 0000000..0a7e429
--- /dev/null
+++ b/scripts/simulate_resources.js
@@ -0,0 +1,244 @@
+// Headless resource-economy simulation, used to tune the costs in
+// shared/data/resources.js. It boots a game, optionally raises a fixed set of
+// resource buildings for every nation, runs a number of in-game days and prints
+// how the economy settles: what each nation spends on power and goods, whether
+// its people are fed, and how each producer's build cost pays back at market
+// prices.
+//
+// node scripts/simulate_resources.js
+// node scripts/simulate_resources.js --days 720 --civs 2 --scenario infra
+// node scripts/simulate_resources.js --map full --days 365 --civs 6
+
+import { GameState } from "../shared/game_state.js";
+import { CIVILISATIONS, MAP_CONFIG } from "../shared/data.js";
+import { RESOURCE_BUILDINGS, RESOURCE_RULES, RESOURCE_MARKET_BASE } from "../shared/data/resources.js";
+import {
+ cityEnergyPerDay,
+ foodNeedPerDay,
+ steelNeedPerDay,
+ luxuryNeedPerDay,
+} from "../shared/resources.js";
+import { key } from "../shared/hex.js";
+
+const args = parseArgs(process.argv.slice(2));
+const DAYS = Number(args.days || 360);
+const CIVS = Number(args.civs || 2);
+const SCENARIO = args.scenario || "baseline";
+const MAP = args.map || "medium";
+const seed = Number(args.seed || 12345);
+
+const state = new GameState();
+state.mapConfig = mapConfig(MAP);
+const civs = CIVILISATIONS.slice(0, Math.max(1, Math.min(CIVS, CIVILISATIONS.length)));
+state.configure(civs, seed);
+
+if (SCENARIO === "infra") seedInfrastructure(state);
+if (SCENARIO === "developed") seedBuildings(state);
+
+const startBudget = civs.map((_, civ) => state.getBudget(civ));
+const spendTotals = civs.map(() => ({ energy: 0, goods: 0, transport: 0, construction: 0, upkeep: 0, combat: 0 }));
+
+for (let day = 0; day < DAYS; day++) {
+ for (let hour = 0; hour < 24; hour++) state.advanceHour();
+ for (let civ = 0; civ < civs.length; civ++) {
+ const record = state.resourceSpend.get(civ) || {};
+ for (const kind of Object.keys(spendTotals[civ])) {
+ spendTotals[civ][kind] += record[kind] || 0;
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+function parseArgs(list) {
+ const out = {};
+ for (let i = 0; i < list.length; i++) {
+ const item = list[i];
+ if (!item.startsWith("--")) continue;
+ const name = item.slice(2);
+ const next = list[i + 1];
+ if (next === undefined || next.startsWith("--")) out[name] = true;
+ else {
+ out[name] = next;
+ i += 1;
+ }
+ }
+ return out;
+}
+
+function mapConfig(kind) {
+ if (kind === "full") return MAP_CONFIG;
+ return {
+ ...MAP_CONFIG,
+ mapSize: { x: 48, y: 40 },
+ minCapitalLandmass: 8,
+ minCapitalDistance: 4,
+ citiesPerCiv: 4,
+ citySpacing: 3,
+ minContinents: 2,
+ smallIslandCount: 2,
+ };
+}
+
+// A representative national grid: one coal plant per city, one steel mill, one
+// chip foundry and a gold mine, all dropped on free land so the steady state
+// can be read without waiting for construction.
+function seedInfrastructure(state) {
+ for (let civ = 0; civ < state.civilisations.length; civ++) {
+ const plan = [
+ "coal_power_plant",
+ "coal_power_plant",
+ "coal_power_plant",
+ "coal_power_plant",
+ "coal_power_plant",
+ "steel_mill",
+ "chip_foundry",
+ "gold_mine",
+ ];
+ const tiles = (state._territoryByCiv.get(civ) || []).filter(
+ (coords) => state._isLand(coords) && !state.cityAt(coords)
+ );
+ let used = 0;
+ for (const id of plan) {
+ if (used >= tiles.length) break;
+ const coords = tiles[used++];
+ const k = key(coords.x, coords.y);
+ state.tileImprovements.set(k, id);
+ state.tileImprovementOwner.set(k, civ);
+ state.tileImprovementHp.set(k, 1000);
+ state._ensureResourceStock(k);
+ }
+ }
+}
+
+// A mature nation: every city carries a believable basket of buildings, so the
+// material upkeep (and repair bill) can be read.
+function seedBuildings(state) {
+ const basket = {
+ market: 3, bank: 2, barracks: 2, shelters: 1, aa_building: 1,
+ research_lab: 2, university: 1, museum: 1, cinema_studio: 1, airport: 1,
+ };
+ for (const city of state.cities) {
+ for (const [id, level] of Object.entries(basket)) {
+ const index = state.protoBuildings.findIndex((b) => b.id === id);
+ if (index < 0) continue;
+ if (state.protoBuildings[index].coastal && !state.isCoastalCity(city)) continue;
+ city.buildings[index] = level;
+ }
+ }
+ state._gdpPerCapitaCache.clear();
+ state._touchModifiers();
+}
+
+function eur(value) {
+ const abs = Math.abs(value);
+ if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
+ if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
+ if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}k`;
+ return value.toFixed(0);
+}
+
+function num(value) {
+ const abs = Math.abs(value);
+ if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}e9`;
+ if (abs >= 1e6) return `${(value / 1e6).toFixed(2)}e6`;
+ if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}e3`;
+ return value.toFixed(1);
+}
+
+function pad(text, width, right = false) {
+ const value = String(text);
+ return right ? value.padStart(width) : value.padEnd(width);
+}
+
+console.log(`\n=== Resource production costs (base market prices, ${DAYS} days) ===`);
+console.log(
+ pad("building", 26) + pad("out/day", 12, true) + pad("energy/day", 13, true) +
+ pad("energy €/d", 11, true) + pad("upkeep €/d", 11, true) + pad("gross €/d", 11, true) +
+ pad("net €/d", 11, true) + pad("payback d", 10, true)
+);
+for (const building of RESOURCE_BUILDINGS) {
+ const energyPrice = RESOURCE_MARKET_BASE.energy;
+ const outputValue = building.resource === "energy"
+ ? building.outputPerDay * RESOURCE_MARKET_BASE.energy
+ : building.outputPerDay * (RESOURCE_MARKET_BASE[building.resource] || 0);
+ const energyCost = (building.energyPerDay || 0) * energyPrice;
+ const upkeep = building.maintenance * 24;
+ const net = outputValue - energyCost - upkeep;
+ const payback = net > 0 ? building.buildCost / net : Infinity;
+ console.log(
+ pad(building.id, 26) + pad(num(building.outputPerDay), 12, true) +
+ pad(num(building.energyPerDay || 0), 13, true) + pad(eur(energyCost), 11, true) +
+ pad(eur(upkeep), 11, true) + pad(eur(outputValue), 11, true) +
+ pad(eur(net), 11, true) + pad(Number.isFinite(payback) ? payback.toFixed(0) : "never", 10, true)
+ );
+}
+
+console.log(`\n=== Settled economy after ${DAYS} days (scenario: ${SCENARIO}, map: ${MAP}) ===`);
+for (let civ = 0; civ < civs.length; civ++) {
+ const population = state.getPlayerPopulation(civ);
+ const gdp = state.getPlayerGdp(civ);
+ const income = state.getCivHourlyIncome(civ) * 24;
+ const moneyUpkeep = state.getPlayerUpkeep(civ) * 24;
+ const upkeepDemand = state.getCivUpkeepResources(civ);
+ const spend = spendTotals[civ];
+ let energyNeed = 0;
+ let energySupply = 0;
+ for (const city of state.cities) {
+ if (city.civ !== civ) continue;
+ energyNeed += cityEnergyPerDay(state.getCityEconomy(city).gdp);
+ }
+ for (const [k, id] of state.tileImprovements) {
+ if (state.tileImprovementOwner.get(k) !== civ) continue;
+ const proto = RESOURCE_BUILDINGS.find((b) => b.id === id);
+ if (proto && proto.resource === "energy") energySupply += proto.outputPerDay;
+ }
+ const stock = state.getCivResourceSummary(civ);
+ console.log(
+ `\n-- ${civs[civ].name} --`
+ );
+ console.log(
+ `pop ${num(population)} gdp ${eur(gdp)} gdp/cap ${eur(population ? gdp / population : 0)}` +
+ ` budget ${eur(state.getBudget(civ))} (start ${eur(startBudget[civ])})`
+ );
+ console.log(
+ `income/d ${eur(income)} campaign upkeep/d ${eur(moneyUpkeep)} net/d ${eur(income - moneyUpkeep)}`
+ );
+ console.log(
+ `material upkeep/d: steel ${num(upkeepDemand.steel)} t energy ${num(upkeepDemand.energy)} kWh` +
+ ` high-tech ${num(upkeepDemand.hightech)} u`
+ );
+ console.log(
+ `resource spend over run: energy ${eur(spend.energy)} goods ${eur(spend.goods)}` +
+ ` transport ${eur(spend.transport)} construction ${eur(spend.construction)}` +
+ ` upkeep ${eur(spend.upkeep)} combat ${eur(spend.combat)}`
+ );
+ console.log(
+ `energy need/d ${num(energyNeed)} kWh own supply/d ${num(energySupply)} kWh` +
+ ` bought-ish/d ${num((spend.energy / Math.max(DAYS, 1)) / Math.max(state.getResourcePrice("energy"), 1e-9))} kWh`
+ );
+ for (const id of ["food", "steel", "luxury", "hightech"]) {
+ console.log(
+ ` ${pad(id, 9)} stock ${num(stock.stock[id])} need/d ${num(stock.need[id] || 0)}` +
+ ` produced/d ${num(stock.production[id] || 0)}`
+ );
+ }
+ let shortage = 0;
+ for (const city of state.cities) {
+ if (city.civ !== civ) continue;
+ const info = state.getCityResourceInfo(city);
+ for (const value of Object.values(info.shortage)) shortage += value;
+ }
+ console.log(` summed city shortage fraction: ${shortage.toFixed(3)}`);
+}
+
+console.log("\n=== Market prices at the end ===");
+for (const id of Object.keys(RESOURCE_MARKET_BASE)) {
+ const stats = state.resourceMarketStats.get(id) || {};
+ console.log(
+ ` ${pad(id, 9)} price ${state.getResourcePrice(id).toFixed(4)}` +
+ ` (base ${RESOURCE_MARKET_BASE[id]}) supply ${num(stats.supply || 0)} demand ${num(stats.demand || 0)}`
+ );
+}
+console.log(`\nRules: food ${RESOURCE_RULES.foodPerPersonPerDay} t/pp/d, steel ${RESOURCE_RULES.steelPerPersonPerDay} t/pp/d, luxury ${RESOURCE_RULES.luxuryPerPersonPerDay} ct/pp/d`);
+console.log(`energy intensity ${RESOURCE_RULES.gdpEnergyIntensity} kWh/€/yr, steel ${RESOURCE_RULES.energyPerSteelTonne / 1e6} GWh/t, food ${RESOURCE_RULES.energyPerFoodTonne / 1e6} GWh/t\n`);
diff --git a/server/game_server.js b/server/game_server.js
index f14de2a..b730943 100644
--- a/server/game_server.js
+++ b/server/game_server.js
@@ -21,6 +21,7 @@ const DELTA_COLLECTIONS = [
"tileEthnicity",
"cityStats",
"visible",
+ "resourceGraph",
];
// A wire move order carrying more units than this is split into chunks that
@@ -216,6 +217,8 @@ export class GameServer extends EventEmitter {
return this._handlePolicy(civ, order);
case "revoke_policy":
return this._handleRevokePolicy(civ, order);
+ case "set_interest_rate":
+ return this.state.requestSetInterestRate(civ, Number(order.rate));
default:
return false;
}
diff --git a/shared/data/buildings.js b/shared/data/buildings.js
index c07557d..2ead9b1 100644
--- a/shared/data/buildings.js
+++ b/shared/data/buildings.js
@@ -2,7 +2,7 @@
// with the level, with no level cap; upkeep is 1% of that cost, so it follows
// the level too.
-import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
+import { EFFECT_PRODUCTION, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
// Buildings whose benefit is a rule rather than a stat effect. Kept as ids so
// the simulation and the UI can name them without magic strings.
@@ -15,8 +15,8 @@ export const BUILDING_MECHANIC = {
CIVILIAN_SHELTER: "civilian_shelter",
// Fires one volley at hostile aircraft passing over the city.
AIR_DEFENSE: "air_defense",
- // Flat GDP per capita for the city's region, per adjacent sea tile.
- PORT_GDP: "port_gdp",
+ // Flat production per head for the city's region, per adjacent sea tile.
+ PORT_PRODUCTION: "port_production",
// Lets people fly in from any other city with an airport.
AIR_IMMIGRATION: "air_immigration",
};
@@ -25,11 +25,11 @@ export const BUILDINGS = [
{
id: "market",
name: "Market",
- description: "A hub of local trade. Raises the GDP per capita of the city's region, with diminishing returns.",
+ description: "A hub of local trade. Raises the production of the city's region, with diminishing returns.",
baseCost: 500_000,
costAlpha: 1.2,
refundFraction: 0.5,
- effects: [effect(EFFECT_GDP, 0.04, 0.75)],
+ effects: [effect(EFFECT_PRODUCTION, 0.04, 0.75)],
icon: "icon_market.svg",
},
{
@@ -70,6 +70,7 @@ export const BUILDINGS = [
},
{
id: "aa_building",
+ advanced: true,
name: "Air defense",
description: "Damages hostile aircraft that fly above this city.",
baseCost: 1_000_000_000,
@@ -77,10 +78,14 @@ export const BUILDINGS = [
refundFraction: 0.5,
effects: [],
mechanic: "air_defense",
+ // Cheap to keep ready; the cost is in the ammunition it fires (see the
+ // combat consumption rules).
+ materialUpkeep: { steel: 0.5, energy: 500, hightech: 0 },
icon: "icon_aa_building.svg",
},
{
id: "research_lab",
+ advanced: true,
name: "Research lab",
description: "Applies science to industry and government. Produces research points for the whole nation.",
baseCost: 100_000_000,
@@ -91,6 +96,7 @@ export const BUILDINGS = [
},
{
id: "university",
+ advanced: true,
name: "University",
description: "Higher education produces research and a little culture for the whole nation.",
baseCost: 100_000_000,
@@ -122,26 +128,30 @@ export const BUILDINGS = [
{
id: "port",
name: "Port",
- description: "Warm water port. Gains 1000 GDP/capita per tile per level.",
+ description: "Warm water port. Adds 1000 production per person per adjacent sea tile per level, and brings in extra food.",
baseCost: 20_000_000,
costAlpha: 1.7,
refundFraction: 0.5,
effects: [effect(EFFECT_BUDGET_INCOME, 0.08, 0.7)],
- // Every tile the city's region covers gains this much GDP per capita for
- // each sea tile next to the city, per port level.
- mechanic: "port_gdp",
- gdpPerCapitaPerSeaTilePerLevel: 1000,
+ // Every person in the city's region produces this much more, for each sea
+ // tile next to the city, per port level.
+ mechanic: "port_production",
+ productionPerSeaTilePerLevel: 1000,
+ // The region also grows this much more food, as a fraction of its output,
+ // for each sea tile beside the city, per port level.
+ foodPerSeaTilePerLevel: 0.05,
icon: "icon_port.svg",
coastal: true,
},
{
id: "airport",
+ advanced: true,
name: "Airport",
description: "An airfield with a control tower. Lets the city train aircraft and serves as a home airbase for them to land and refuel. Immigration can reach this city from any other city in the world with an airport. The effect scales with airport level.",
baseCost: 250_000_000,
costAlpha: 1.7,
refundFraction: 0.5,
- effects: [effect(EFFECT_GDP, 0.05, 0.8)],
+ effects: [effect(EFFECT_PRODUCTION, 0.05, 0.8)],
mechanic: "air_immigration",
icon: "icon_airport.svg",
},
diff --git a/shared/data/civilisations.js b/shared/data/civilisations.js
index d53ea86..7396c63 100644
--- a/shared/data/civilisations.js
+++ b/shared/data/civilisations.js
@@ -1,11 +1,12 @@
-// The playable civilisations: colours, flag art and the pool of city names
-// drawn on when settlements are placed.
+// The playable civilisations: colours, flag art, national currency and the pool
+// of city names drawn on when settlements are placed.
export const CIVILISATIONS = [
{
id: "france",
name: "France",
demonym: "French",
+ currency: { code: "FRF", symbol: "₣", name: "French Franc" },
primaryColour: "#0d0d5c",
accentColour: "#8c8c82",
flag: "flag_france.png",
@@ -15,6 +16,7 @@ export const CIVILISATIONS = [
id: "britain",
name: "Britain",
demonym: "British",
+ currency: { code: "GBP", symbol: "£", name: "British Pound" },
primaryColour: "#a81818",
accentColour: "#0722ff",
flag: "flag_britain.png",
@@ -24,6 +26,7 @@ export const CIVILISATIONS = [
id: "slovenia",
name: "Slovenia",
demonym: "Slovenian",
+ currency: { code: "SIT", symbol: "SIT", name: "Slovenian Tolar" },
primaryColour: "#005da4",
accentColour: "#ed1c24",
flag: "flag_slovenia.png",
@@ -33,6 +36,7 @@ export const CIVILISATIONS = [
id: "poland",
name: "Poland",
demonym: "Polish",
+ currency: { code: "PLN", symbol: "zł", name: "Polish Złoty" },
primaryColour: "#a6000b",
accentColour: "#ffffff",
flag: "flag_poland.png",
@@ -42,6 +46,7 @@ export const CIVILISATIONS = [
id: "switzerland",
name: "Switzerland",
demonym: "Swiss",
+ currency: { code: "CHF", symbol: "₣", name: "Swiss Franc" },
primaryColour: "#da291c",
accentColour: "#ffffff",
flag: "flag_switzerland.png",
@@ -51,6 +56,7 @@ export const CIVILISATIONS = [
id: "ticino",
name: "Ticino",
demonym: "Ticinese",
+ currency: { code: "TIF", symbol: "₣", name: "Ticinese Franc" },
primaryColour: "#4646e0",
accentColour: "#a6000b",
flag: "flag_ticino.png",
@@ -60,6 +66,7 @@ export const CIVILISATIONS = [
id: "galicia",
name: "Galicia",
demonym: "Galician",
+ currency: { code: "GAP", symbol: "₧", name: "Galician Peseta" },
primaryColour: "#006dad",
accentColour: "#ffffff",
flag: "flag_galicia.png",
@@ -69,6 +76,7 @@ export const CIVILISATIONS = [
id: "germany",
name: "Germany",
demonym: "German",
+ currency: { code: "DEM", symbol: "DM", name: "German Mark" },
primaryColour: "#141414",
accentColour: "#ffce00",
flag: "flag_germany.png",
@@ -78,6 +86,7 @@ export const CIVILISATIONS = [
id: "iran",
name: "Iran",
demonym: "Iranian",
+ currency: { code: "IRR", symbol: "﷼", name: "Iranian Rial" },
primaryColour: "#18a645",
accentColour: "#f01c21",
flag: "flag_iran.png",
@@ -87,6 +96,7 @@ export const CIVILISATIONS = [
id: "usa",
name: "United States",
demonym: "American",
+ currency: { code: "USD", symbol: "$", name: "US Dollar" },
primaryColour: "#3c3b6e",
accentColour: "#b22234",
flag: "flag_usa.png",
@@ -97,6 +107,7 @@ export const CIVILISATIONS = [
name: "Aotearoa",
demonym: "New Zealander",
adjective: "New Zealand",
+ currency: { code: "NZD", symbol: "$", name: "New Zealand Dollar" },
primaryColour: "#00247d",
accentColour: "#cc142b",
flag: "flag_aotearoa.png",
@@ -107,6 +118,7 @@ export const CIVILISATIONS = [
name: "New Hampshire",
demonym: "New Hampshirite",
adjective: "New Hampshire",
+ currency: { code: "NHD", symbol: "$", name: "New Hampshire Dollar" },
primaryColour: "#12355b",
accentColour: "#f2b705",
flag: "flag_new_hampshire.png",
@@ -116,6 +128,7 @@ export const CIVILISATIONS = [
id: "florida",
name: "Florida",
demonym: "Floridian",
+ currency: { code: "FLD", symbol: "$", name: "Floridian Dollar" },
primaryColour: "#782f40",
accentColour: "#ceb888",
flag: "flag_florida.png",
@@ -125,6 +138,7 @@ export const CIVILISATIONS = [
id: "australia",
name: "Australia",
demonym: "Australian",
+ currency: { code: "AUD", symbol: "$", name: "Australian Dollar" },
primaryColour: "#00843d",
accentColour: "#ffcd00",
flag: "flag_australia.png",
@@ -134,6 +148,7 @@ export const CIVILISATIONS = [
id: "uruguay",
name: "Uruguay",
demonym: "Uruguayan",
+ currency: { code: "UYU", symbol: "$U", name: "Uruguayan Peso" },
primaryColour: "#0038a8",
accentColour: "#fcd116",
flag: "flag_uruguay.png",
@@ -143,6 +158,7 @@ export const CIVILISATIONS = [
id: "vietnam",
name: "Vietnam",
demonym: "Vietnamese",
+ currency: { code: "VND", symbol: "₫", name: "Vietnamese Đồng" },
primaryColour: "#da251d",
accentColour: "#ffff00",
flag: "flag_vietnam.png",
@@ -152,6 +168,7 @@ export const CIVILISATIONS = [
id: "texas",
name: "Texas",
demonym: "Texan",
+ currency: { code: "TXD", symbol: "$", name: "Texan Dollar" },
primaryColour: "#bf0a30",
accentColour: "#002868",
flag: "flag_texas.png",
@@ -161,6 +178,7 @@ export const CIVILISATIONS = [
id: "alabama",
name: "Alabama",
demonym: "Alabamian",
+ currency: { code: "ALD", symbol: "$", name: "Alabaman Dollar" },
primaryColour: "#9e1b32",
accentColour: "#ffffff",
flag: "flag_alabama.png",
diff --git a/shared/data/combat.js b/shared/data/combat.js
index c895571..20c4533 100644
--- a/shared/data/combat.js
+++ b/shared/data/combat.js
@@ -7,11 +7,11 @@ export const CITY_GARRISON_HEAL_PER_HOUR = 1;
// Combat is fought on a single tile: the attacking stack charges onto the
// defending stack's tile and the two share it until one side is wiped out.
// Every day a battle lasts the tile loses this fraction of its population and
-// of its GDP per capita. Once the fighting stops, GDP per capita recovers by
+// of its production per head. Once the fighting stops, production recovers by
// `recoveryPerDay` each day, starting `recoveryDelayDays` after the last battle.
export const BATTLE = {
populationLossPerDay: 0.3,
- gdpPerCapitaLossPerDay: 0.3,
+ productionLossPerDay: 0.3,
recoveryDelayDays: 30,
recoveryPerDay: 100,
// Structures on a battlefield take this much damage each day of fighting.
@@ -19,19 +19,19 @@ export const BATTLE = {
};
// Pillaging: each unit removes this fraction of the tile's remaining
-// population, cuts its GDP per capita by this multiplier, and heals one hit
-// point per this many people driven off.
+// population, cuts its production per head by this multiplier, and heals one
+// hit point per this many people driven off.
export const PILLAGE = {
populationLoss: 0.1,
- gdpPenalty: 0.7,
+ productionPenalty: 0.7,
healPerPeople: 1000,
};
// Trenches: infantry dug into a tile are much harder to dislodge, at the cost
-// of the local economy. The defense figures are multipliers on a unit's base
+// of the local production. The defense figures are multipliers on a unit's base
// defense; melee attackers get no bonus, artillery is heavily blunted.
export const TRENCH = {
- gdpMultiplier: 0.8,
+ productionMultiplier: 0.8,
meleeDefenseMultiplier: 2.0,
artilleryDefenseMultiplier: 3.0,
};
@@ -71,7 +71,7 @@ export const AIR_DEFENSE = {
// The result of pillaging a tile with `count` units in sequence, starting from
// its current population. Each step removes a tenth of what is left and heals
-// for the people it removed; the GDP-per-capita multiplier compounds. Shared so
+// for the people it removed; the production multiplier compounds. Shared so
// the server and the forecast tooltip compute the same numbers.
export function pillageSteps(population, count) {
const steps = [];
@@ -81,5 +81,5 @@ export function pillageSteps(population, count) {
remaining -= removed;
steps.push({ removed, remaining, heal: removed / PILLAGE.healPerPeople });
}
- return { steps, gdpMultiplier: Math.pow(PILLAGE.gdpPenalty, count) };
+ return { steps, productionMultiplier: Math.pow(PILLAGE.productionPenalty, count) };
}
diff --git a/shared/data/economy.js b/shared/data/economy.js
index 2c7cac1..bee9817 100644
--- a/shared/data/economy.js
+++ b/shared/data/economy.js
@@ -8,12 +8,55 @@ import { HOURS_PER_YEAR } from "../game_clock.js";
// rate below is what the per-tick economy actually uses.
export const ANNUAL_TAX_RATE = 0.25;
+// The buckets the monthly budget report groups a nation's spending into. Each
+// row carries a signed cash movement (positive is money into the treasury) and,
+// for the upkeep rows, the market value of the materials consumed whether they
+// were drawn from stores or bought. `group` is the heading the table files the
+// row under.
+export const BUDGET_EXPENSE_CATEGORIES = [
+ { id: "buildings", label: "Building upkeep", group: "Upkeep" },
+ { id: "units", label: "Unit upkeep", group: "Upkeep" },
+ { id: "works", label: "Tile works", group: "Upkeep" },
+ { id: "transport", label: "Transport", group: "Upkeep" },
+ { id: "campaign", label: "Campaign", group: "Government" },
+ { id: "construction", label: "Construction", group: "Investment" },
+ { id: "training", label: "Training", group: "Investment" },
+ { id: "tilebuild", label: "Tile improvements", group: "Investment" },
+ { id: "provisioning", label: "Provisioning", group: "Resources" },
+ { id: "energy", label: "Power", group: "Resources" },
+ { id: "military", label: "Military supplies", group: "Resources" },
+];
+
+// The monetary system: each nation issues its own currency and runs a central
+// bank. Regions are the private sector, each holding a basket of currencies.
+// When their cash runs out they borrow from the central bank, and the same bank
+// lets the government run an overdraft. The interest rate is the cost of that
+// borrowing, not a direct lever on inflation.
+export const MONEY = {
+ // The central bank's opening lending rate, charged on every debt each year,
+ // and the ceiling a player may set.
+ defaultInterestRate: 0.02,
+ maxInterestRate: 0.5,
+ // Private-sector cash a region opens with, as a share of its yearly GDP.
+ privateCashFraction: 0.5,
+ // Each day a region converts this share of every foreign holding back into its
+ // own currency.
+ repatriationPerDay: 0.05,
+ // How much a currency's value may move from par, either way.
+ maxRateDrift: 0.9,
+ // How hard a day's net foreign demand pushes a currency's value.
+ demandSensitivity: 0.5,
+ // When several regions chase the same scarce surplus, each further buyer
+ // raises the price it pays by this share, capped at `maxBidMultiplier`.
+ bidPremium: 0.3,
+ maxBidMultiplier: 2,
+};
+
export const ECONOMY = {
- baseGdpPerCapita: 10_000,
basePopulation: 100_000,
- cityTileGdpMultiplier: 2.0,
- capitalTileGdpMultiplier: 2.5,
- citySurroundingGdpBonus: 0.3,
+ cityTileProductionMultiplier: 2.0,
+ capitalTileProductionMultiplier: 2.5,
+ citySurroundingProductionBonus: 0.3,
gdpPerProductionPoint: 100_000,
// 25% of GDP per year, spread evenly over the hours of the year.
taxRatePerYear: ANNUAL_TAX_RATE,
@@ -22,10 +65,10 @@ export const ECONOMY = {
startingBudgetFraction: 0.1,
startingBudget: 1_000_000_000,
basePopulationGrowthRate: 0.005,
- // Natural growth of GDP per capita: every day a tile gains this share of the
- // GDP per capita it opened the game with. The amount is constant, so the
- // relative growth rate naturally tapers off as the years pass.
- naturalGdpGrowthPerDay: 0.0001,
+ // Natural growth of production: every day a tile gains this share of the
+ // production it opened the game with. The amount is constant, so the relative
+ // growth rate naturally tapers off as the years pass.
+ naturalProductionGrowthPerDay: 0.0001,
productionCapacity(gdp) {
if (this.gdpPerProductionPoint <= 0) return 0;
diff --git a/shared/data/effects.js b/shared/data/effects.js
index babcafb..f5ba9b5 100644
--- a/shared/data/effects.js
+++ b/shared/data/effects.js
@@ -2,7 +2,8 @@
// technologies). An effect scales with a level so a building's benefit grows
// with each upgrade.
-export const EFFECT_GDP = "gdp";
+// Every stat that modifies a tile's or a nation's production, in display order.
+export const EFFECT_PRODUCTION = "production";
export const EFFECT_RESEARCH = "research";
export const EFFECT_CULTURE = "culture";
export const EFFECT_BUDGET_INCOME = "budget_income";
diff --git a/shared/data/governments.js b/shared/data/governments.js
index c605e78..e2118fa 100644
--- a/shared/data/governments.js
+++ b/shared/data/governments.js
@@ -1,7 +1,7 @@
// Forms of government a civilisation may adopt. Each grants a flat modifier to
// the stats it favours.
-import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
+import { EFFECT_PRODUCTION, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
export const GOVERNMENTS = [
{
@@ -10,7 +10,7 @@ export const GOVERNMENTS = [
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, the treasury and the sciences.",
baseCost: 0,
costGdpFraction: 0.02,
- effects: [effect(EFFECT_GDP, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
+ effects: [effect(EFFECT_PRODUCTION, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
},
{
id: "monarchy",
@@ -18,7 +18,7 @@ export const GOVERNMENTS = [
description: "Rule by a hereditary sovereign. The crown favours commerce and tradition, lifting the economy and the nation's cultural output.",
baseCost: 0,
costGdpFraction: 0.03,
- effects: [effect(EFFECT_GDP, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)],
+ effects: [effect(EFFECT_PRODUCTION, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)],
},
{
id: "technocracy",
@@ -26,7 +26,7 @@ export const GOVERNMENTS = [
description: "Rule by technical experts. A single-minded drive for progress pours resources into research while economic growth takes second place.",
baseCost: 0,
costGdpFraction: 0.04,
- effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_GDP, 0.03, 1.0)],
+ effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_PRODUCTION, 0.03, 1.0)],
},
];
diff --git a/shared/data/improvements.js b/shared/data/improvements.js
index 2dd1478..aa45ad2 100644
--- a/shared/data/improvements.js
+++ b/shared/data/improvements.js
@@ -3,6 +3,8 @@
// again as a road. Both are bought outright and then cost an hourly maintenance
// for every tile of the network a nation holds.
+import { resourceBuildingById } from "./resources.js";
+
export const TRANSPORT_IMPROVEMENTS = [
{
id: "road",
@@ -57,6 +59,7 @@ export const TILE_IMPROVEMENTS = [
},
{
id: "radar_tower",
+ advanced: true,
name: "Radar tower",
description:
"Watches five tiles out. A ranged hit damages it and an enemy walking onto it captures it.",
@@ -69,6 +72,7 @@ export const TILE_IMPROVEMENTS = [
},
{
id: "coastal_cannon",
+ advanced: true,
name: "Coastal cannon",
description:
"Automatically fires on hostile warships within two tiles. Only a coastal tile can carry one.",
@@ -86,6 +90,13 @@ export const TILE_IMPROVEMENT_BY_ID = Object.fromEntries(
TILE_IMPROVEMENTS.map((improvement) => [improvement.id, improvement])
);
+// A tile-owned building is either a military improvement or a resource
+// producer. Both share the tile-improvement maps, the tile-works construction
+// queue and the hourly upkeep, so the lookup folds the two catalogues together.
export function tileImprovementById(id) {
- return TILE_IMPROVEMENT_BY_ID[id] || null;
+ return TILE_IMPROVEMENT_BY_ID[id] || resourceBuildingById(id) || null;
+}
+
+export function isResourceTileBuilding(proto) {
+ return !!(proto && proto.resource && proto.outputPerDay !== undefined);
}
diff --git a/shared/data/index.js b/shared/data/index.js
index be60aa5..efaa9d0 100644
--- a/shared/data/index.js
+++ b/shared/data/index.js
@@ -16,4 +16,5 @@ export * from "./politics.js";
export * from "./map_config.js";
export * from "./terrain.js";
export * from "./roads.js";
+export * from "./resources.js";
export * from "./improvements.js";
diff --git a/shared/data/politics.js b/shared/data/politics.js
index f73d388..286e123 100644
--- a/shared/data/politics.js
+++ b/shared/data/politics.js
@@ -160,6 +160,38 @@ export const MIGRATION = {
airportSharePerDay: 0.0005,
// The approval at or above which nobody is inclined to leave.
comfortableApproval: 0.9,
+
+ // People move only to a region at least this much richer per head, and the
+ // bar rises with the travel time: a destination `time` hours away must be
+ // `incomeRatioBase * (1 + time * incomeTimePenalty)` times as rich. So the
+ // closer and richer a place is, the more attractive.
+ incomeRatioBase: 2,
+ incomeTimePenalty: 0.02,
+ // People travel the transport network; a tile of open terrain costs this many
+ // hours at walking pace, a road or railway less, open water a flat sea rate.
+ peopleSpeed: 1,
+ seaHoursPerTile: 1.5,
+ // Land routes are only traced this far; beyond it a destination is reachable
+ // only by air.
+ maxTravelHours: 120,
+ // Of the people who qualify, this share of the poorer region moves per day,
+ // scaled by how far the ratio clears the bar and capped.
+ incomeSharePerDay: 0.0015,
+ incomeGapCap: 1,
+ // No single day moves more than this share of a region's people by land.
+ incomeMaxSharePerDay: 0.005,
+ // An airport flies people over the distance at a flat time cost, but moves at
+ // most this many people per day for each airport level on the route.
+ airHours: 6,
+ airPeoplePerDayPerLevel: 1000,
+
+ // At generation, regions of a nation trade people until their production per
+ // head is level. `equalizeTolerance` is the residual spread accepted and
+ // `equalizePasses` bounds the refinement; no region is emptied below
+ // `minRegionPopulation`.
+ equalizeTolerance: 0.005,
+ equalizePasses: 30,
+ minRegionPopulation: 20000,
};
// A pillaged tile is a war crime. The people of the tile loathe the aggressor
diff --git a/shared/data/resources.js b/shared/data/resources.js
new file mode 100644
index 0000000..f724143
--- /dev/null
+++ b/shared/data/resources.js
@@ -0,0 +1,414 @@
+// Resources: the five material groups nations produce, store, trade and
+// consume. Everything is expressed in one canonical unit per resource so the
+// simulation never has to convert: energy in kWh, steel and food in tonnes,
+// luxury in carats and high-tech in whole units. Display formatting converts to
+// the friendly unit (MWh, Mt, kt, ...). The market prices and conversion ratios
+// 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.
+
+// 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;
+export const KWH_PER_TWH = 1_000_000_000;
+
+// One tonne of any material counts as this much energy on the wire, so material
+// deliveries and energy deliveries share one transport-cost formula.
+export const ENERGY_PER_MATERIAL_TONNE = 10 * KWH_PER_GWH; // 10 GWh/t
+
+export const RESOURCES = [
+ {
+ id: "energy",
+ name: "Energy",
+ unit: "kWh",
+ icon: "icon_electricity.svg",
+ colour: "#ffd633",
+ // Mass of one canonical unit, so a delivery leg can charge transport per
+ // tonne whatever the good is: 10 GWh of energy weighs a tonne.
+ tonnesPerUnit: 1 / ENERGY_PER_MATERIAL_TONNE,
+ // Energy is a flow, not a stock: it comes from the grid and is spent the
+ // same day. Only cities and resource buildings can hold the other four.
+ storable: false,
+ },
+ {
+ id: "steel",
+ colour: "#9aa7b8",
+ name: "Steel",
+ unit: "t",
+ icon: "icon_steel.svg",
+ tonnesPerUnit: 1,
+ storable: true,
+ },
+ {
+ id: "food",
+ colour: "#7ed957",
+ name: "Food",
+ unit: "t",
+ icon: "icon_food.svg",
+ tonnesPerUnit: 1,
+ storable: true,
+ },
+ {
+ id: "luxury",
+ colour: "#d98cff",
+ name: "Luxury",
+ unit: "ct",
+ icon: "icon_luxury.svg",
+ // A carat is 0.2 g, so 200,000 ct weigh a tonne.
+ tonnesPerUnit: 0.0002 / 1000,
+ storable: true,
+ },
+ {
+ id: "hightech",
+ colour: "#5cc8ff",
+ name: "High-tech",
+ unit: "u",
+ icon: "icon_hightech.svg",
+ // A high-tech unit is a crate of components, about a kilogram.
+ tonnesPerUnit: 0.001,
+ storable: true,
+ },
+];
+
+export const RESOURCE_IDS = RESOURCES.map((r) => r.id);
+export const RESOURCE_INDEX = Object.fromEntries(RESOURCES.map((r, i) => [r.id, i]));
+// 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);
+
+export function resourceById(id) {
+ return RESOURCES[RESOURCE_INDEX[id]] || null;
+}
+
+// Global market prices, in euro per canonical unit. These are what a city pays
+// when it cannot meet a need from its own store or a neighbour. Energy is
+// deliberately cheap: the roadmap's energy-per-material ratios are large, so a
+// high electric price would make every material's energy bill dwarf its value.
+//
+// The five figures are the old nominal prices raised roughly seventeen-fold so
+// the production-based GDP (what a nation makes times these prices) lands in
+// the same range as the money economy it replaced. The scaling is what makes a
+// fresh nation's food output alone read as a plausible per-capita GDP rather
+// than a few hundred euro; construction, upkeep and imports are priced off the
+// same figures.
+export const RESOURCE_MARKET_BASE = {
+ energy: 0.051, // per kWh
+ steel: 10_200, // per tonne
+ food: 5_100, // per tonne
+ luxury: 1_020, // per carat
+ hightech: 13_600, // per unit
+};
+
+// How strongly a day's world-wide imbalance moves the price, and how far it may
+// drift from the base. The market is a smoothing device, not a real auction:
+// it lets a shortage raise the cost of imports and a glut cheapen them.
+export const RESOURCE_MARKET = {
+ base: RESOURCE_MARKET_BASE,
+ adjustment: 0.2,
+ minMultiplier: 0.5,
+ maxMultiplier: 1.8,
+};
+
+// The physical rules of the resource economy. Every per-person figure and every
+// energy-per-material ratio below is fixed by the design; the prices above are
+// what we tune against them.
+export const RESOURCE_RULES = {
+ // Each person eats 1 kg of food and consumes 1 kg of steel a day. Both are
+ // canonical tonnes, so 1 kg is 0.001 t.
+ foodPerPersonPerDay: 0.001,
+ steelPerPersonPerDay: 0.001,
+ // Luxuries are rarer: 0.01 carat a person a day.
+ luxuryPerPersonPerDay: 0.01,
+ // Cities aim to hold 130% of a month's food before the month begins.
+ foodMonthlyBuffer: 1.3,
+ daysPerMonth: 30,
+
+ // A city's baseline electric appetite, in kWh per euro of yearly GDP. This is
+ // the "kWh per euro of GDP" economic metric; the top bar does not show it.
+ gdpEnergyIntensity: 0.15,
+
+ // Energy the material producers consume to make one canonical unit.
+ energyPerFoodTonne: 1 * KWH_PER_MWH, // 1 MWh per tonne
+ energyPerSteelTonne: 20 * KWH_PER_MWH, // 20 MWh per tonne
+ energyPerLuxuryCarat: {
+ gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat
+ diamond_mine: 5 * KWH_PER_MWH, // 5 MWh per carat
+ },
+ energyPerHighTechUnit: 100 * KWH_PER_MWH, // 100 MWh per unit
+ // Science buildings spend one high-tech unit for every research point.
+ highTechPerResearchPoint: 1,
+
+ // Delivering one tonne of material one tile, in kWh of transport energy.
+ // Roads use trucks, railways trains; without a road a truck burns the
+ // off-road figure times the tile's movement cost, and sea legs go by ship.
+ deliveryEnergy: {
+ truckRoad: 10 * KWH_PER_MWH, // 10 MWh per tonne per tile
+ truckOffRoad: 50 * KWH_PER_MWH, // 50 MWh per tonne per tile, times cost
+ train: 1 * KWH_PER_MWH, // 1 MWh per tonne per tile
+ ship: 50, // 50 kWh per tonne per tile
+ },
+
+ // Steel every construction spends per euro of its money cost. At 1/10000 a
+ // 100 M building needs 10,000 t of steel.
+ steelPerBudgetEuro: 1 / 10_000,
+ // The electricity a tile work draws as it is built, per euro of its money
+ // cost. It is roughly the power embodied in the steel it spends: steel costs
+ // 20 MWh a tonne and 1 t per 10,000 euro, so building is powered as well as
+ // supplied. Tile improvements pay this up front.
+ constructionEnergyPerBudgetEuro: 2, // kWh per euro
+ // High-tech a science or advanced building needs to be raised, per level.
+ constructionHighTechPerLevel: 100,
+
+ // Upkeep is materials, not money: every building, unit and improvement wears
+ // out and the state buys what it needs. Each figure scales with the thing's
+ // money value -- a building level's cost, a unit's training cost -- so one set
+ // of coefficients tunes the whole load. `advanced` things (science, air,
+ // radar, chips, fusion) also need high-tech.
+ upkeepSteelPerEuro: 5e-7, // t per euro of value per day
+ upkeepEnergyPerEuro: 1e-3, // kWh per euro of value per day
+ upkeepHighTechPerEuro: 2e-6, // u per euro of value per day (advanced only)
+
+ // Transport is the exception: its build cost is enormous but its upkeep is a
+ // per-tile wear figure, not a share of that cost.
+ transportUpkeepSteel: { road: 0.02, railway: 0.1 },
+
+ // Combat is the other exception: guns and aircraft are cheap to keep parked
+ // and expensive to fire. These are the materials one use spends. Air defense
+ // fires per hostile aircraft pass, artillery per hourly shelling, an aircraft
+ // per sortie.
+ combatConsumption: {
+ airDefenseVolley: { steel: 20, hightech: 5 },
+ artilleryVolley: { steel: 1, hightech: 0 },
+ airStrike: { steel: 100, hightech: 20 },
+ },
+
+ // Infantry eat. A unit carries this many soldiers, and each is fed like a
+ // civilian: double rations in combat, triple while wounded and resting.
+ // Training or healing a unit draws its soldiers out of the region's people.
+ unitPopulation: 1_000,
+ infantryCombatFoodMultiplier: 2,
+ infantryRestingFoodMultiplier: 3,
+
+ // Supply. A unit carries this many days of its own food. At home, or in
+ // foreign territory with a clear route to an allied city, it tops up from
+ // that city; cut off, the days run down and the unit starts to starve.
+ unitSupplyDays: 50,
+ starvationHpFractionPerDay: 0.1,
+
+ // Repairs: damage leaves a materials debt the owner pays down, and only then
+ // does the structure recover. A razed city level costs what it cost to build.
+ repair: {
+ steelPerTileHp: 0.05, // t to restore one hit point of a tile work
+ tileHpPerDay: 60, // hit points a funded repair restores each day
+ steelPerDamage: 0.5, // t of debt per point of structure damage
+ unitSteelPerHp: 0.01, // t to heal one hit point of a ground or naval unit
+ unitHighTechPerHp: 0.002, // u to heal one hit point of an advanced unit
+ },
+
+ // Fraction of a missing need that turns into a popularity penalty per day,
+ // and the same for a GDP-per-capita shortfall, so shortages are felt.
+ shortagePopularityPenalty: 2,
+ shortageGdpPenalty: 0.05,
+
+ // Food comes from the land, not from the number of people standing on it:
+ // every worked tile grows `foodPerTilePerDay` tonnes a day scaled by its
+ // terrain, so moving people between regions changes only income per head.
+ foodPerTilePerDay: 100,
+ // How fertile each terrain is. A worked tile grows its base output scaled by
+ // this, so forests and plains feed cities while deserts and ice do not.
+ tileFoodFactor: {
+ Forest: 1.4,
+ Hills: 0.7,
+ Land: 1.2,
+ Desert: 0.1,
+ Mountain: 0.2,
+ Tundra: 0.2,
+ Ice: 0,
+ },
+};
+
+// How far a city will look for a neighbour's surplus before falling back to the
+// global market.
+export const RESOURCE_TRADE_RADIUS = 8;
+
+// 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
+// and produces its material. `outputPerDay` is in the resource's canonical
+// unit, `energyPerDay` in kWh (positive means consumed, negative produced).
+export const RESOURCE_BUILDINGS = [
+ {
+ id: "coal_power_plant",
+ name: "Coal power plant",
+ description:
+ "Burns coal to make 250 GWh of electricity a day. Cheap to raise, but the fuel bill never stops.",
+ resource: "energy",
+ outputPerDay: 250 * KWH_PER_GWH,
+ fuelEnergyPerDay: 250 * KWH_PER_GWH * 0.25,
+ energyPerDay: 0,
+ buildCost: 500_000_000,
+ maintenance: 20_000,
+ icon: "icon_coal_power_plant.svg",
+ },
+ {
+ id: "natural_gas_power_plant",
+ name: "Natural gas power plant",
+ description:
+ "The cleanest fossil plant: 300 GWh a day for a higher fuel bill.",
+ resource: "energy",
+ outputPerDay: 300 * KWH_PER_GWH,
+ fuelEnergyPerDay: 300 * KWH_PER_GWH * 0.25,
+ energyPerDay: 0,
+ buildCost: 700_000_000,
+ maintenance: 25_000,
+ icon: "icon_gas_power_plant.svg",
+ },
+ {
+ id: "oil_power_plant",
+ name: "Oil power plant",
+ description: "Burns oil for 270 GWh of electricity a day.",
+ resource: "energy",
+ outputPerDay: 270 * KWH_PER_GWH,
+ fuelEnergyPerDay: 270 * KWH_PER_GWH * 0.25,
+ energyPerDay: 0,
+ buildCost: 600_000_000,
+ maintenance: 22_000,
+ icon: "icon_oil_power_plant.svg",
+ },
+ {
+ id: "solar_power_plant",
+ name: "Solar power plant",
+ description:
+ "A field of panels producing a steady 30 GWh a day with no fuel and no upkeep.",
+ resource: "energy",
+ outputPerDay: 30 * KWH_PER_GWH,
+ energyPerDay: 0,
+ renewable: true,
+ buildCost: 300_000_000,
+ maintenance: 0,
+ icon: "icon_solar_power_plant.svg",
+ },
+ {
+ id: "geothermal_plant",
+ name: "Geothermal plant",
+ description: "Taps the earth's heat for 60 GWh a day of free electricity.",
+ resource: "energy",
+ outputPerDay: 60 * KWH_PER_GWH,
+ energyPerDay: 0,
+ renewable: true,
+ buildCost: 800_000_000,
+ maintenance: 0,
+ icon: "icon_geothermal_plant.svg",
+ },
+ {
+ id: "wind_turbines",
+ name: "Wind turbines",
+ description: "A hilltop wind farm making 25 GWh a day for free.",
+ resource: "energy",
+ outputPerDay: 25 * KWH_PER_GWH,
+ energyPerDay: 0,
+ renewable: true,
+ buildCost: 200_000_000,
+ maintenance: 0,
+ icon: "icon_wind_turbines.svg",
+ },
+ {
+ id: "offshore_wind_turbines",
+ name: "Offshore wind turbines",
+ description: "Turbines out at sea making 50 GWh a day. Needs a coastal tile.",
+ resource: "energy",
+ outputPerDay: 50 * KWH_PER_GWH,
+ energyPerDay: 0,
+ renewable: true,
+ coastal: true,
+ buildCost: 500_000_000,
+ maintenance: 0,
+ icon: "icon_offshore_wind_turbines.svg",
+ },
+ {
+ id: "fusion_power_plant",
+ advanced: true,
+ name: "Fusion power plant",
+ description: "A late-game reactor producing 4 TWh of electricity a day.",
+ resource: "energy",
+ outputPerDay: 4_000 * KWH_PER_GWH,
+ energyPerDay: 0,
+ renewable: true,
+ buildCost: 50_000_000_000,
+ maintenance: 50_000,
+ // A wonder of the endgame: only a nation that has mastered fusion may raise
+ // one. Its upkeep is deliberately high but not the raw share of its enormous
+ // build cost.
+ requiresTechnology: "fusion_power",
+ materialUpkeep: { steel: 1_000, energy: 0, hightech: 500 },
+ icon: "icon_fusion_power_plant.svg",
+ },
+ {
+ id: "steel_mill",
+ name: "Steel mill",
+ description:
+ "Smelts iron into 2000 t of steel a day, spending 20 MWh of energy per tonne.",
+ resource: "steel",
+ outputPerDay: 2_000,
+ energyPerDay: 2_000 * RESOURCE_RULES.energyPerSteelTonne,
+ buildCost: 800_000_000,
+ maintenance: 10_000,
+ icon: "icon_steel_mill.svg",
+ },
+ {
+ id: "gold_mine",
+ name: "Gold mine",
+ description: "Produces 20,000 carats of luxury a day, at 10 MWh a carat.",
+ resource: "luxury",
+ outputPerDay: 20_000,
+ energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.gold_mine,
+ buildCost: 400_000_000,
+ maintenance: 5_000,
+ icon: "icon_gold_mine.svg",
+ },
+ {
+ id: "diamond_mine",
+ name: "Diamond mine",
+ description: "Produces 20,000 carats of luxury a day, at 5 MWh a carat.",
+ resource: "luxury",
+ outputPerDay: 20_000,
+ energyPerDay: 20_000 * RESOURCE_RULES.energyPerLuxuryCarat.diamond_mine,
+ buildCost: 500_000_000,
+ maintenance: 5_000,
+ icon: "icon_diamond_mine.svg",
+ },
+ {
+ id: "chip_foundry",
+ advanced: true,
+ name: "Chip foundry",
+ description:
+ "Fabricates 5000 units of high-tech a day, spending 100 MWh of energy per unit.",
+ resource: "hightech",
+ outputPerDay: 5_000,
+ energyPerDay: 5_000 * RESOURCE_RULES.energyPerHighTechUnit,
+ buildCost: 2_000_000_000,
+ maintenance: 10_000,
+ icon: "icon_chip_foundry.svg",
+ },
+];
+
+// One work stands for a whole industry: its output is scaled up so a nation
+// needs far fewer of them on the map. The energy and fuel it draws scale with
+// the output too, so a converter stays exactly as efficient as it was authored.
+export const RESOURCE_BUILDING_OUTPUT_SCALE = 10;
+for (const building of RESOURCE_BUILDINGS) {
+ building.outputPerDay = (building.outputPerDay || 0) * RESOURCE_BUILDING_OUTPUT_SCALE;
+ building.energyPerDay = (building.energyPerDay || 0) * RESOURCE_BUILDING_OUTPUT_SCALE;
+ building.fuelEnergyPerDay = (building.fuelEnergyPerDay || 0) * RESOURCE_BUILDING_OUTPUT_SCALE;
+}
+
+// The resource buildings decay in battle like the military improvements do.
+for (const building of RESOURCE_BUILDINGS) {
+ if (building.takesCombatDamage === undefined) building.takesCombatDamage = true;
+}
+
+export const RESOURCE_BUILDING_BY_ID = Object.fromEntries(
+ RESOURCE_BUILDINGS.map((building) => [building.id, building])
+);
+
+export function resourceBuildingById(id) {
+ return RESOURCE_BUILDING_BY_ID[id] || null;
+}
diff --git a/shared/data/technologies.js b/shared/data/technologies.js
index d1d801e..e91b401 100644
--- a/shared/data/technologies.js
+++ b/shared/data/technologies.js
@@ -8,7 +8,7 @@
// news event (it only notifies the player).
import {
- EFFECT_GDP,
+ EFFECT_PRODUCTION,
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
@@ -36,17 +36,17 @@ export const TECHNOLOGIES = [
theme: "science",
cost: 20,
prerequisites: [],
- effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)],
+ effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_PRODUCTION, 0.02, 1.0)],
},
{
id: "industrial_automation",
name: "Industrial Automation",
- description: "Robotics and control systems take over the factory floor, lifting national output.",
+ description: "Robotics and control systems take over the factory floor, lifting national production.",
theme: "economy",
repeatable: true,
cost: 60,
prerequisites: ["scientific_method"],
- effects: [effect(EFFECT_GDP, 0.08, 1.0)],
+ effects: [effect(EFFECT_PRODUCTION, 0.08, 1.0)],
},
{
id: "renewable_energy",
@@ -55,7 +55,7 @@ export const TECHNOLOGIES = [
theme: "economy",
cost: 80,
prerequisites: ["scientific_method"],
- effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
+ effects: [effect(EFFECT_PRODUCTION, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
},
{
id: "quantum_computing",
@@ -73,7 +73,17 @@ export const TECHNOLOGIES = [
theme: "science",
cost: 300,
prerequisites: ["industrial_automation", "quantum_computing"],
- effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)],
+ effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_PRODUCTION, 0.05, 1.0)],
+ },
+ {
+ id: "fusion_power",
+ name: "Fusion Power",
+ description: "Controlled fusion. The key to the fusion power plant, and the most expensive research a nation can undertake.",
+ theme: "science",
+ // 500,000 research points, a lifetime's work for a mature nation.
+ cost: 5_000,
+ prerequisites: ["space_program", "quantum_computing"],
+ effects: [effect(EFFECT_RESEARCH, 8.0, 1.0)],
},
// Repeatable levels. Each raises a single stat by 1% of its base value.
@@ -138,11 +148,17 @@ export function technologyById(id) {
return TECHNOLOGIES.find((t) => t.id === id) || null;
}
+// Research is deliberately expensive: the catalogue lists the thematic base
+// price, and every effective cost is scaled by this factor. Keeping the scale
+// in one place lets the whole tree be retuned without touching each entry.
+export const RESEARCH_COST_MULTIPLIER = 100;
+
// The cost of the next level of a technology given how many times it has been
// researched already. Non-repeatable technologies cost the same either way.
export function technologyCost(proto, repeatCount = 0) {
if (!proto) return 0;
- if (!proto.repeatable) return proto.cost;
+ const base = proto.cost * RESEARCH_COST_MULTIPLIER;
+ if (!proto.repeatable) return base;
const growth = proto.repeatCostGrowth || 0;
- return proto.cost * Math.pow(1 + growth, Math.max(0, repeatCount));
+ return base * Math.pow(1 + growth, Math.max(0, repeatCount));
}
diff --git a/shared/data/terrain.js b/shared/data/terrain.js
index bf4cf07..5e9aa15 100644
--- a/shared/data/terrain.js
+++ b/shared/data/terrain.js
@@ -5,14 +5,14 @@
// the fraction added to a unit's defense while it stands on the tile.
export const TERRAIN_TILES = [
- { col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 },
- { col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 },
- { col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 },
- { col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 },
- { col: 4, terrainType: "Desert", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 },
- { col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
- { col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 },
- { col: 6, terrainType: "Tundra", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 2.0, defenseBonus: 0, minElevation: 0 },
+ { col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, productionMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 },
+ { col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, productionMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 },
+ { col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, productionMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 },
+ { col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, productionMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 },
+ { col: 4, terrainType: "Desert", terrainClass: "Land", populationMultiplier: 0.05, productionMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 },
+ { col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, productionMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
+ { col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, productionMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 },
+ { col: 6, terrainType: "Tundra", terrainClass: "Land", populationMultiplier: 0.05, productionMultiplier: 0.2, movementCostMultiplier: 2.0, defenseBonus: 0, minElevation: 0 },
];
// A city standing on a tile adds this fraction on top of the terrain's own
diff --git a/shared/data/units.js b/shared/data/units.js
index 7d6cb6f..3c70e18 100644
--- a/shared/data/units.js
+++ b/shared/data/units.js
@@ -7,6 +7,7 @@
export const PROTO_UNITS = [
{
id: "modern_infantry",
+ population: 1_000,
name: "Modern infantry",
maxHp: 500,
cost: 100_000_000,
@@ -40,6 +41,7 @@ export const PROTO_UNITS = [
},
{
id: "artillery",
+ materialUpkeep: { steel: 2, energy: 2000, hightech: 0 },
name: "Artillery",
maxHp: 400,
cost: 300_000_000,
@@ -59,6 +61,8 @@ export const PROTO_UNITS = [
},
{
id: "jet_fighter",
+ materialUpkeep: { steel: 5, energy: 5000, hightech: 1 },
+ advanced: true,
name: "Jet fighter",
maxHp: 300,
cost: 400_000_000,
@@ -83,6 +87,8 @@ export const PROTO_UNITS = [
},
{
id: "bomber",
+ materialUpkeep: { steel: 8, energy: 8000, hightech: 2 },
+ advanced: true,
name: "Bomber",
maxHp: 350,
cost: 500_000_000,
@@ -106,6 +112,7 @@ export const PROTO_UNITS = [
},
{
id: "battleship",
+ advanced: true,
name: "Battleship",
maxHp: 800,
cost: 600_000_000,
@@ -123,6 +130,7 @@ export const PROTO_UNITS = [
},
{
id: "mobile_radar",
+ advanced: true,
name: "Mobile radar",
maxHp: 200,
cost: 50_000_000,
diff --git a/shared/game_state.js b/shared/game_state.js
index 113182e..4417edf 100644
--- a/shared/game_state.js
+++ b/shared/game_state.js
@@ -46,6 +46,9 @@ import { visibilityMethods } from "./game_state/visibility.js";
import { serializationMethods } from "./game_state/serialization.js";
import { monthlyMethods } from "./game_state/monthly.js";
import { warfareMethods } from "./game_state/warfare.js";
+import { resourceMethods } from "./game_state/resources.js";
+import { budgetMethods } from "./game_state/budget.js";
+import { moneyMethods } from "./game_state/money.js";
export {
MAX_NEWS,
@@ -119,11 +122,11 @@ export class GameState {
// railway lists are rebuilt only when something actually moved.
this._improvementVersion = 0;
this.tilePopulation = new Map();
- // Per-tile GDP-per-capita multiplier, lowered by pillaging (default 1).
- this.tileGdpPenalty = new Map();
- // Absolute GDP-per-capita lost to fighting on a tile, and the hour its
+ // Per-tile production-per-head multiplier, lowered by pillaging (default 1).
+ this.tileProductionPenalty = new Map();
+ // Absolute production per head lost to fighting on a tile, and the hour its
// recovery may begin. A tile leaves both maps once it has recovered.
- this.tileBattleGdpDeficit = new Map();
+ this.tileBattleProductionDeficit = new Map();
this.tileBattleRecoveryStart = new Map();
this.budgets = new Map();
this.training = new Map();
@@ -177,11 +180,11 @@ export class GameState {
this._populationVersion = 0;
this._ethnicityVersion = 0;
this._visibleVersion = 0;
- // Each land tile's opening GDP per capita, the constant natural growth is
- // measured from. Static once the world exists.
- this._gdpBaselineVersion = 0;
- this._serializedGdpBaselineCache = null;
- this.tileGdpBaseline = new Map();
+ // Each land tile's opening production per head, the constant natural growth
+ // is measured from. Static once the world exists.
+ this._productionBaselineVersion = 0;
+ this._serializedProductionBaselineCache = null;
+ this.tileProductionBaseline = new Map();
// Approval is expensive to weight over every tile and is read far more
// often than politics changes, so both the per-city and per-nation results
// are memoised against the politics version (and cleared on territory
@@ -210,9 +213,9 @@ export class GameState {
this._cityEconomyCache = new Map();
this._cityEconomyCacheActive = false;
// Per-civ factors that map generation picked so every nation starts with
- // the same population and GDP per capita (see _balanceStartingEconomy).
+ // the same population and production per head (see _balanceStartingEconomy).
this._populationBalance = new Map();
- this._perCapitaBalance = new Map();
+ this._productionBalance = new Map();
this._serializedTerritory = [];
this._serializedExplored = new Map();
this._serializedTileEthnicityCache = null;
@@ -259,6 +262,12 @@ export class GameState {
// the browser rebuilds the very same islands.
this.mapConfig = { ...this.mapConfig, nationCount: this.civilisations.length };
this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed();
+ // The small islands become the first capitals, so a nation's position in
+ // this list decides whether it starts island-bound. Shuffle the countries
+ // from the world seed so the same few nations are not always the ones
+ // seeded onto the islands. Players pick their nation from this same order,
+ // and the snapshot ships it, so client and server stay in step.
+ new Random((this.seed ^ 0x1b873593) >>> 0).shuffle(this.civilisations);
// Combat rolls (a city losing a building level to a bombardment) draw from
// their own generator, seeded from the world so a game is reproducible.
this._combatRng = new Random((this.seed ^ 0x5bd1e995) >>> 0);
@@ -277,11 +286,13 @@ export class GameState {
this._configured = true;
this._placeSettlements();
+ this._initResources();
+ this._initMoney();
this._refreshStatuses();
this._revealAll();
this._refreshVisibility();
this._initMonthly();
- this._initNaturalGdpGrowth();
+ this._initNaturalProductionGrowth();
this._visibilityDirty = false;
this._emitChanged();
}
@@ -339,7 +350,7 @@ export class GameState {
tickHour() {
this.totalHours += 1;
- // Natural growth is a whole number of days, so the cached per-capita figures
+ // Natural growth is a whole number of days, so the cached production figures
// only go stale when a new day begins.
if (this.totalHours % HOURS_PER_DAY === 0) this._gdpPerCapitaCache.clear();
this._tickMonthly();
@@ -357,8 +368,12 @@ export class GameState {
this._tickPopulation();
this._tickPolitics();
this._tickBattleTiles();
- this._tickGdpRecovery();
+ this._tickProductionRecovery();
+ this._tickMoney();
this._tickEconomy();
+ this._tickResources();
+ this._tickRepairs();
+ this._tickUnitSupply();
this._refreshStatuses();
this._applyDailyStatusEffects();
this._resolveCombatRound();
@@ -392,5 +407,8 @@ Object.assign(
visibilityMethods,
serializationMethods,
monthlyMethods,
- warfareMethods
+ warfareMethods,
+ resourceMethods,
+ budgetMethods,
+ moneyMethods
);
diff --git a/shared/game_state/air.js b/shared/game_state/air.js
index 0b23ddc..bb49c63 100644
--- a/shared/game_state/air.js
+++ b/shared/game_state/air.js
@@ -5,6 +5,7 @@
import { key } from "../hex.js";
import { AIR_DEFENSE, AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js";
import { BUILDING_MECHANIC } from "../data/buildings.js";
+import { RESOURCE_RULES } from "../data/resources.js";
export const airMethods = {
// ----------------------------------------------------- building rules --
@@ -210,13 +211,15 @@ export const airMethods = {
}
const perCapita = this.getTileGdpPerCapita(city.coords);
if (perCapita > 0) {
- const deficit = (this.tileBattleGdpDeficit.get(k) || 0) + perCapita * 0.1;
- this.tileBattleGdpDeficit.set(k, deficit);
+ const deficit = (this.tileBattleProductionDeficit.get(k) || 0) + perCapita * 0.1;
+ this.tileBattleProductionDeficit.set(k, deficit);
this.tileBattleRecoveryStart.set(k, this.totalHours + 30 * 24);
this._gdpPerCapitaCache.delete(k);
}
this._damageTileImprovement(city.coords, Math.max(1, Math.round(amount * 0.1)));
this._razeCityBuildings(city, amount);
+ // The people and their city need materials to put themselves right again.
+ this._recordRepairDebt(city.coords, amount);
this._emitChanged();
},
@@ -248,6 +251,9 @@ export const airMethods = {
if (chance > 0 && this._random() < chance) {
if (level <= 1) delete city.buildings[index];
else city.buildings[index] = level - 1;
+ // The level is not lost for good: it waits to be rebuilt once its
+ // materials are paid for.
+ this._queueCityRepair(city, Number(index), level);
razed = true;
}
}
@@ -266,6 +272,9 @@ export const airMethods = {
if (!city || city.civ === unit.civ) return false;
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_DEFENSE);
if (level <= 0) return false;
+ // Air defense is cheap to keep ready and costly to fire: no ammunition,
+ // no volley.
+ if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) return false;
const damage = Math.max(1, Math.round(AIR_DEFENSE.damagePerLevel * level));
unit.hp -= damage;
if (unit.hp > 0) return false;
@@ -384,8 +393,8 @@ export const airMethods = {
if (path.length < 2) return false;
paths.push(path);
}
- const cost = (AIR_STRIKE.cost || 0) * units.length;
- if (cost > 0 && !this._spendBudget(civ, cost)) return false;
+ // A sortie is paid for in munitions, not money: no materials, no strike.
+ if (!this.payCombatResources(civ, "airStrike", units.length)) return false;
this._declareWar(civ, target ? target.civ : city.civ);
units.forEach((unit, index) => {
unit.path = paths[index];
@@ -436,7 +445,11 @@ export const airMethods = {
// No garrison left: the battery shells the city itself.
const city = this._strikeCityAt(goal, unit.civ);
if (city && this._strikeReady(unit, goal)) {
- this._bombardCity(city, Math.max(1, Math.round(this._unitAttack(unit) * (BOMBARD.hourlyDamageFraction || 0) * 10)));
+ if (!this.payCombatResources(unit.civ, "artilleryVolley", 1)) {
+ unit.strikeTarget = null;
+ } else {
+ this._bombardCity(city, Math.max(1, Math.round(this._unitAttack(unit) * (BOMBARD.hourlyDamageFraction || 0) * 10)));
+ }
} else {
unit.strikeTarget = null;
}
@@ -446,6 +459,11 @@ export const airMethods = {
unit.strikeTarget = null;
continue;
}
+ // Each hourly volley is a shell spent.
+ if (!this.payCombatResources(unit.civ, "artilleryVolley", 1)) {
+ unit.strikeTarget = null;
+ continue;
+ }
const full = this._unitAttack(unit) - this._effectiveDefense(target, true, this.unitProto(unit));
const amount = Math.max(1, Math.round(full * (BOMBARD.hourlyDamageFraction || 0)));
damage.set(target, (damage.get(target) || 0) + amount);
@@ -461,9 +479,13 @@ export const airMethods = {
// ---------------------------------------------------------- training --
// Places a freshly trained unit: aircraft and land units appear in the city,
- // while a warship is launched onto the least crowded adjacent sea tile.
+ // while a warship is launched onto the least crowded adjacent sea tile. The
+ // unit's soldiers are drawn at random from the region's people.
_spawnTrainedUnit(city, proto) {
- return this._spawnUnit(this._spawnCoordsFor(city, proto), city.civ, proto, city.id);
+ const unit = this._spawnUnit(this._spawnCoordsFor(city, proto), city.civ, proto, city.id);
+ const soldiers = proto.population || RESOURCE_RULES.unitPopulation;
+ if (soldiers > 0) this.drawRegionPopulation(city, soldiers);
+ return unit;
},
_spawnCoordsFor(city, proto) {
diff --git a/shared/game_state/budget.js b/shared/game_state/budget.js
new file mode 100644
index 0000000..30ada73
--- /dev/null
+++ b/shared/game_state/budget.js
@@ -0,0 +1,176 @@
+// The monthly budget ledger. Money moves in and out of a nation's treasury all
+// day; this keeps a running total for the calendar month, filed under the
+// expense categories the budget screen shows, so the panel can report what the
+// current month has cost so far and what each past month cost.
+//
+// Two figures are kept per category. `cash` is the money that actually left the
+// treasury; `economic` is the market value of the materials the category
+// consumed, whether they were drawn from the nation's stores or bought -- the
+// upkeep rows consume stock for free, so their economic cost is the truer
+// measure of what they took. Income and resource sales are the positive side.
+//
+// On every calendar-month rollover the live ledger is frozen into the history
+// and a fresh one begins, so the panel can page back through the last two years.
+
+import { BUDGET_EXPENSE_CATEGORIES } from "../data/economy.js";
+import { MONTH_NAMES } from "../game_clock.js";
+
+// How many completed months the history keeps. Two years is enough to compare a
+// season with the one before it without growing the snapshot without bound.
+export const BUDGET_HISTORY_MONTHS = 24;
+
+function emptyBudgetLedger() {
+ const cash = { income: 0, sell: 0 };
+ const economic = {};
+ for (const category of BUDGET_EXPENSE_CATEGORIES) {
+ cash[category.id] = 0;
+ economic[category.id] = 0;
+ }
+ // Money spent per category, broken down by the resource it bought, so an
+ // expense row can expand to show what it consumed and what it paid.
+ const resourceByCategory = {};
+ for (const category of BUDGET_EXPENSE_CATEGORIES) resourceByCategory[category.id] = {};
+ return { cash, economic, resourceBuy: {}, resourceSell: {}, resourceByCategory };
+}
+
+export const budgetMethods = {
+ _initBudgetHistory() {
+ this.budgetLedger = new Map();
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ this.budgetLedger.set(civ, emptyBudgetLedger());
+ }
+ // Frozen months, oldest first: [{ monthIndex, records: [ledger, ...] }].
+ this.budgetHistory = [];
+ },
+
+ _budgetRecord(civ) {
+ if (!this.budgetLedger) this._initBudgetHistory();
+ let record = this.budgetLedger.get(civ);
+ if (!record) {
+ record = emptyBudgetLedger();
+ this.budgetLedger.set(civ, record);
+ }
+ return record;
+ },
+
+ // A signed cash movement for the month: positive is money into the treasury
+ // (tax, a resource sale, a refund), negative is money spent. Recorded once per
+ // category so the report can total them.
+ _recordBudgetCash(civ, category, amount) {
+ if (civ < 0 || !(amount !== 0)) return;
+ const record = this._budgetRecord(civ);
+ record.cash[category] = (record.cash[category] || 0) + amount;
+ },
+
+ // The market value of the materials one category consumed, whatever their
+ // source, so an upkeep row can show what it cost even when the stores paid.
+ _recordBudgetEconomic(civ, category, amount) {
+ if (civ < 0 || !(amount > 0)) return;
+ const record = this._budgetRecord(civ);
+ record.economic[category] = (record.economic[category] || 0) + amount;
+ },
+
+ // The money one expense category spent on one resource, so the row can be
+ // expanded to show what it bought.
+ _recordBudgetCategoryResource(civ, category, resource, amount) {
+ if (civ < 0 || !(amount > 0)) return;
+ const record = this._budgetRecord(civ);
+ const byResource = record.resourceByCategory[category]
+ || (record.resourceByCategory[category] = {});
+ byResource[resource] = (byResource[resource] || 0) + amount;
+ },
+
+ // The money a nation spent buying and earned selling one resource to the
+ // world market or another country, for the month's resource-trade memo.
+ _recordBudgetResource(civ, resource, bought, sold) {
+ if (civ < 0) return;
+ const record = this._budgetRecord(civ);
+ if (bought > 0) record.resourceBuy[resource] = (record.resourceBuy[resource] || 0) + bought;
+ if (sold > 0) record.resourceSell[resource] = (record.resourceSell[resource] || 0) + sold;
+ },
+
+ // Freezes the month that just ended and starts a new ledger. Called from the
+ // monthly rollover with the month index that is being closed.
+ _rolloverBudget(previousIndex) {
+ if (!this.budgetLedger) return;
+ const records = [];
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ records.push(this.budgetLedger.get(civ) || emptyBudgetLedger());
+ }
+ this.budgetHistory.push({ monthIndex: previousIndex, records });
+ while (this.budgetHistory.length > BUDGET_HISTORY_MONTHS) this.budgetHistory.shift();
+ this.budgetLedger = new Map();
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ this.budgetLedger.set(civ, emptyBudgetLedger());
+ }
+ },
+
+ // Every month the panel may show, oldest first, with the live month last.
+ budgetMonthReports(civ) {
+ const reports = [];
+ for (const entry of this.budgetHistory || []) {
+ reports.push(this._budgetReport(civ, entry.monthIndex, entry.records[civ], false));
+ }
+ const currentIndex = this._monthIndexOf(this.totalHours);
+ reports.push(this._budgetReport(
+ civ,
+ currentIndex,
+ this.budgetLedger ? this.budgetLedger.get(civ) : null,
+ true
+ ));
+ return reports;
+ },
+
+ _budgetReport(civ, monthIndex, record, isCurrent) {
+ const cash = (record && record.cash) || {};
+ const economic = (record && record.economic) || {};
+ const expenses = [];
+ let totalCash = 0;
+ let totalEconomic = 0;
+ const resourceByCategory = (record && record.resourceByCategory) || {};
+ for (const category of BUDGET_EXPENSE_CATEGORIES) {
+ const spent = Math.max(0, -(cash[category.id] || 0));
+ const cost = Math.max(0, economic[category.id] || 0, spent);
+ if (spent === 0 && cost === 0) continue;
+ const buys = Object.entries(resourceByCategory[category.id] || {})
+ .filter(([, amount]) => amount > 0)
+ .sort((a, b) => b[1] - a[1]);
+ expenses.push({
+ id: category.id,
+ label: category.label,
+ group: category.group,
+ cash: spent,
+ economic: cost,
+ buys,
+ });
+ totalCash += spent;
+ totalEconomic += cost;
+ }
+ const resourceBuy = (record && record.resourceBuy) || {};
+ const resourceSell = (record && record.resourceSell) || {};
+ const resourceIds = new Set([...Object.keys(resourceBuy), ...Object.keys(resourceSell)]);
+ const resourceTrade = Array.from(resourceIds)
+ .map((id) => ({
+ id,
+ bought: Math.max(0, resourceBuy[id] || 0),
+ sold: Math.max(0, resourceSell[id] || 0),
+ }))
+ .filter((entry) => entry.bought > 0 || entry.sold > 0);
+ const income = cash.income || 0;
+ const sales = cash.sell || 0;
+ const year = Math.floor(monthIndex / 12);
+ const month = monthIndex % 12;
+ return {
+ monthIndex,
+ isCurrent,
+ label: `${MONTH_NAMES[month]} ${year}`,
+ income,
+ sales,
+ expenses,
+ resourceTrade,
+ totalCash,
+ totalEconomic,
+ net: income + sales - totalCash,
+ };
+ },
+};
diff --git a/shared/game_state/combat.js b/shared/game_state/combat.js
index 2f9d9dd..a989941 100644
--- a/shared/game_state/combat.js
+++ b/shared/game_state/combat.js
@@ -359,8 +359,8 @@ export const combatMethods = {
},
// Every tile where a battle is raging loses a share of its population and its
- // GDP per capita each day, and its recovery clock is pushed back to 30 days
- // after the last day of fighting.
+ // production per head each day, and its recovery clock is pushed back to 30
+ // days after the last day of fighting.
_tickBattleTiles() {
this._populationVersion += 1;
for (const battle of this._battleTiles()) {
@@ -372,11 +372,11 @@ export const combatMethods = {
this._recordBattleCivilianCasualties(battle, removed);
}
const perCapita = this.getTileGdpPerCapita(battle.coords);
- let scarred = this.tileBattleGdpDeficit.has(k);
+ let scarred = this.tileBattleProductionDeficit.has(k);
if (perCapita > 0) {
- const deficit = (this.tileBattleGdpDeficit.get(k) || 0) +
- perCapita * BATTLE.gdpPerCapitaLossPerDay;
- this.tileBattleGdpDeficit.set(k, deficit);
+ const deficit = (this.tileBattleProductionDeficit.get(k) || 0) +
+ perCapita * BATTLE.productionLossPerDay;
+ this.tileBattleProductionDeficit.set(k, deficit);
this._gdpPerCapitaCache.delete(k);
scarred = true;
}
@@ -391,18 +391,21 @@ export const combatMethods = {
}
},
- // Once a battlefield has been quiet for `recoveryDelayDays`, its GDP per
- // capita climbs back by `recoveryPerDay` a day until the scar is gone.
- _tickGdpRecovery() {
- for (const [k, deficit] of Array.from(this.tileBattleGdpDeficit)) {
+ // Once a battlefield has been quiet for `recoveryDelayDays`, its production
+ // per head climbs back by `recoveryPerDay` a day until the scar is gone.
+ _tickProductionRecovery() {
+ for (const [k, deficit] of Array.from(this.tileBattleProductionDeficit)) {
const start = this.tileBattleRecoveryStart.get(k);
if (start === undefined || this.totalHours < start) continue;
+ // A tile still carrying battle damage it has not paid to repair does not
+ // recover: the materials come first.
+ if ((this.tileRepairDebt.get(k) || 0) > 0.0001) continue;
const remaining = deficit - BATTLE.recoveryPerDay;
if (remaining <= 0) {
- this.tileBattleGdpDeficit.delete(k);
+ this.tileBattleProductionDeficit.delete(k);
this.tileBattleRecoveryStart.delete(k);
} else {
- this.tileBattleGdpDeficit.set(k, remaining);
+ this.tileBattleProductionDeficit.set(k, remaining);
}
this._gdpPerCapitaCache.delete(k);
}
diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js
index fefbf2f..f2aa884 100644
--- a/shared/game_state/economy.js
+++ b/shared/game_state/economy.js
@@ -2,8 +2,9 @@
// research/culture accrual and the daily economic ticks.
import { ECONOMY } from "../data/economy.js";
+import { RESOURCE_RULES, RESOURCE_IDS } from "../data/resources.js";
import {
- EFFECT_GDP,
+ EFFECT_PRODUCTION,
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
@@ -16,15 +17,20 @@ import { technologyCost, technologyById } from "../data/technologies.js";
import { NEWS_TECHNOLOGY } from "../data/relations.js";
import {
buildingUpkeep,
- gdpPerCapitaFactors,
- gdpPerCapitaFromFactors,
+ productionFactors,
+ productionFromFactors,
governmentEffects,
prerequisitesMet,
technologyEffects,
} from "../rules.js";
import { key, parseKey } from "../hex.js";
-import { TRANSPORT_BY_ID } from "../data/improvements.js";
-import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
+import {
+ TRANSPORT_BY_ID,
+ tileImprovementById,
+ isResourceTileBuilding,
+} from "../data/improvements.js";
+import { tileFoodOutput } from "../resources.js";
+import { HOURS_PER_DAY, HOURS_PER_YEAR, DAYS_PER_YEAR } from "./constants.js";
import { effectAt, mergeModifiers } from "./helpers.js";
export const economyMethods = {
@@ -36,22 +42,22 @@ export const economyMethods = {
// The live economy: the raw sums over the civ's territory, adjusted by the
// balancing factors map generation assigned so every civilisation starts with
- // the same population and GDP per capita. The factors stay applied as the raw
- // figures grow, so terrain and war keep shaping the economy without ever
- // recreating the arbitrary starting gap.
+ // the same population and production per head. The factors stay applied as
+ // the raw figures grow, so terrain and war keep shaping the economy without
+ // ever recreating the arbitrary starting gap.
_playerAggregates(civ) {
const base = this._basePlayerAggregates(civ);
const populationScale = this._populationBalance.get(civ) || 1;
- const perCapitaScale = this._perCapitaBalance.get(civ) || 1;
+ const productionScale = this._productionBalance.get(civ) || 1;
return {
population: base.population * populationScale,
- gdp: base.gdp * populationScale * perCapitaScale,
+ gdp: base.gdp * populationScale * productionScale,
};
},
// The economy exactly as the tiles describe it, before balancing. Every
// tile's per-capita figure already carries its region's and its nation's
- // modifiers, so the sum needs no further adjustment.
+ // production modifiers, so the sum needs no further adjustment.
_basePlayerAggregates(civ) {
let population = 0;
let baseGdp = 0;
@@ -73,60 +79,103 @@ export const economyMethods = {
const region = city || this.regionCityAt(coords);
const regional = region ? this.getCityBuildingModifiers(region) : null;
const national = this.getNationalModifiers(owner);
- const factors = gdpPerCapitaFactors(this.tiles[k], {
+ const factors = productionFactors(this.tiles[k], {
cityKind: city ? (city.isCapital ? "capital" : "city") : null,
nextToCity: !city && this._isNextToCity(coords, owner),
- pillagePenalty: this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1,
+ pillagePenalty: this.tileProductionPenalty.get(k) !== undefined
+ ? this.tileProductionPenalty.get(k)
+ : 1,
// Fighting scars the tile too, but that loss recovers over time.
- battleDeficit: this.tileBattleGdpDeficit.get(k) || 0,
+ battleDeficit: this.tileBattleProductionDeficit.get(k) || 0,
trench: this.trenches.has(k),
- regionalMultiplier: 1 + ((regional && regional[EFFECT_GDP]) || 0),
+ regionalMultiplier: 1 + ((regional && regional[EFFECT_PRODUCTION]) || 0),
regionalLabel: region ? region.name : null,
- nationalMultiplier: 1 + (national[EFFECT_GDP] || 0),
- portGdp: this._regionPortGdp(region),
- naturalGrowth: this._naturalTileGdpGrowth(k),
+ nationalMultiplier: 1 + (national[EFFECT_PRODUCTION] || 0),
+ portProduction: this._regionPortProduction(region),
+ naturalGrowth: this._naturalTileProductionGrowth(k),
});
- const perCapita = gdpPerCapitaFromFactors(ECONOMY.baseGdpPerCapita, factors);
+ const perCapita = productionFromFactors(this._regionProductionPerCapita(region), factors);
this._gdpPerCapitaCache.set(k, perCapita);
return perCapita;
},
- // Snapshots every land tile's opening GDP per capita, so natural growth has a
+ // GDP does not start from an abstract base figure: the ground a region covers
+ // produces resources, and their value on the market is what its people make.
+ // The base the production modifiers scale is that regional production, in
+ // euro a year, per head. It carries no city, port or terrain bonus of its own
+ // -- those stay in `productionFactors`, applied on top.
+ _regionProductionPerCapita(region) {
+ if (!region) return 0;
+ const cachedKey = `regionbase:${region.id}`;
+ if (this._gdpPerCapitaCache.has(cachedKey)) return this._gdpPerCapitaCache.get(cachedKey);
+ let population = 0;
+ for (const coords of this.regionTiles(region)) {
+ population += this.tilePopulation.get(key(coords.x, coords.y)) || 0;
+ }
+ const base = population > 0 ? this._regionProductionValue(region) / population : 0;
+ this._gdpPerCapitaCache.set(cachedKey, base);
+ return base;
+ },
+
+ // The market value of everything made across a region in a year: the food its
+ // worked land grows (from the land, not the headcount) plus the output of
+ // every resource building on it, each at the current world price.
+ _regionProductionValue(region) {
+ let daily = 0;
+ const foodMultiplier = this._regionFoodMultiplier(region);
+ for (const coords of this.regionTiles(region)) {
+ const k = key(coords.x, coords.y);
+ const tile = this.tiles[k];
+ if (!tile) continue;
+ const population = this.tilePopulation.get(k) || 0;
+ if (population > 0) {
+ daily += tileFoodOutput(tile) * foodMultiplier * this.getResourcePrice("food");
+ }
+ const id = this.tileImprovements.get(k);
+ const proto = id ? tileImprovementById(id) : null;
+ if (isResourceTileBuilding(proto)) {
+ daily += (proto.outputPerDay || 0) * this.getResourcePrice(proto.resource);
+ }
+ }
+ return daily * DAYS_PER_YEAR;
+ },
+
+ // Snapshots every land tile's opening production, so natural growth has a
// constant to build on. The world and its economy must exist by now.
- _initNaturalGdpGrowth() {
- this.tileGdpBaseline = new Map();
- if (ECONOMY.naturalGdpGrowthPerDay <= 0) return;
+ _initNaturalProductionGrowth() {
+ this.tileProductionBaseline = new Map();
+ if (ECONOMY.naturalProductionGrowthPerDay <= 0) return;
for (const k in this.tiles) {
const coords = parseKey(k);
if (!this._isLand(coords)) continue;
const perCapita = this.getTileGdpPerCapita(coords);
- if (perCapita > 0) this.tileGdpBaseline.set(k, perCapita);
+ if (perCapita > 0) this.tileProductionBaseline.set(k, perCapita);
}
this._gdpPerCapitaCache.clear();
},
- // The flat amount a tile's GDP per capita has gained since January 2000: its
- // opening figure times the daily rate, once per day elapsed. A constant, so
- // the relative growth rate falls as the tile grows richer.
- _naturalTileGdpGrowth(k) {
- const baseline = this.tileGdpBaseline ? this.tileGdpBaseline.get(k) : 0;
+ // The flat amount a tile's production per head has gained since January 2000:
+ // its opening figure times the daily rate, once per day elapsed. A constant,
+ // so the relative growth rate falls as the tile grows richer.
+ _naturalTileProductionGrowth(k) {
+ const baseline = this.tileProductionBaseline ? this.tileProductionBaseline.get(k) : 0;
if (!baseline) return 0;
- return baseline * ECONOMY.naturalGdpGrowthPerDay * this._gdpGrowthDays();
+ return baseline * ECONOMY.naturalProductionGrowthPerDay * this._productionGrowthDays();
},
- _gdpGrowthDays() {
+ _productionGrowthDays() {
return Math.floor(Math.max(0, this.totalHours) / HOURS_PER_DAY);
},
- // The flat GDP per capita a region's ports add to every tile it covers:
+ // The flat production per head a region's ports add to every tile it covers:
// 1000 per level for each sea tile beside the city, summed over its ports.
- _regionPortGdp(region) {
+ _regionPortProduction(region) {
if (!region) return 0;
let bonus = 0;
for (const [index, level] of Object.entries(region.buildings || {})) {
const proto = this.protoBuildings[index];
- if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_GDP) {
- bonus += (proto.gdpPerCapitaPerSeaTilePerLevel || 0) * level;
+ if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
+ bonus += (proto.productionPerSeaTilePerLevel || 0) * level;
}
}
if (bonus === 0) return 0;
@@ -138,8 +187,32 @@ export const economyMethods = {
return bonus * sea;
},
+ // The extra food a region's ports bring in, as a multiplier on its tiles'
+ // output: each port level adds `foodPerSeaTilePerLevel` for every sea tile
+ // beside the port city. Returns 1 when the region has no port, so the common
+ // inland case is free.
+ _regionFoodMultiplier(region) {
+ if (!region) return 1;
+ let bonus = 0;
+ for (const [index, level] of Object.entries(region.buildings || {})) {
+ const proto = this.protoBuildings[index];
+ if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
+ bonus += (proto.foodPerSeaTilePerLevel || 0) * level;
+ }
+ }
+ if (bonus === 0) return 1;
+ let sea = 0;
+ for (const neighbour of this._neighbours(region.coords)) {
+ const tile = this.tiles[key(neighbour.x, neighbour.y)];
+ if (tile && tile.terrainClass === "Sea") sea += 1;
+ }
+ return 1 + bonus * sea;
+ },
+
getTileGdp(coords) {
- return (this.tilePopulation.get(key(coords.x, coords.y)) || 0) * this.getTileGdpPerCapita(coords);
+ // The population is rounded, as everywhere it is shown, so a tile's GDP is
+ // exactly its shown people times its shown production per head.
+ return this.getTilePopulation(coords) * this.getTileGdpPerCapita(coords);
},
getPlayerGdp(civ) {
@@ -147,21 +220,28 @@ export const economyMethods = {
},
getPlayerUpkeep(civ) {
- let total = this.getBuildingUpkeep(civ);
- const battle = this._battleUnitIds();
- for (const unit of this.units) {
- if (unit.civ !== civ) continue;
- const proto = this.unitProto(unit);
- if (!proto) continue;
- const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
- total += proto.upkeep * this._statusUpkeepMultiplier(statuses);
+ // Money upkeep is now only propaganda. Everything physical -- buildings,
+ // units, works, transport -- is maintained in materials and settled by the
+ // daily resource tick, so it never appears as a direct money bill.
+ return this.getCampaignUpkeep(civ);
+ },
+
+ // The maintenance materials one nation's buildings wear out in a day, for the
+ // budget forecast and the resources panel.
+ getBuildingUpkeep(civ) {
+ const total = { steel: 0, energy: 0, hightech: 0 };
+ for (const city of this.cities) {
+ if (city.civ !== civ) continue;
+ for (const [indexStr, level] of Object.entries(city.buildings || {})) {
+ if (level <= 0) continue;
+ const proto = this.protoBuildings[Number(indexStr)];
+ if (!proto) continue;
+ const costs = this.buildingUpkeepResources(proto, level);
+ total.steel += costs.steel;
+ total.energy += costs.energy;
+ total.hightech += costs.hightech;
+ }
}
- // Running propaganda campaigns are upkeep too, paid hour by hour.
- total += this.getCampaignUpkeep(civ);
- // The transport network and military tile improvements cost an hourly
- // maintenance per tile and structure held.
- total += this.getTransportUpkeep(civ);
- total += this.getTileImprovementUpkeep(civ);
return total;
},
@@ -180,12 +260,12 @@ export const economyMethods = {
return counts;
},
- // The hourly maintenance of a nation's transport network: every road tile it
- // holds costs a flat fee, every railway tile three times as much.
+ // The daily steel a nation's transport network wears out: every road tile a
+ // little, every railway tile more.
getTransportUpkeep(civ) {
const counts = this._transportTiles(civ);
- return counts.road * TRANSPORT_BY_ID.road.maintenance +
- counts.railway * TRANSPORT_BY_ID.railway.maintenance;
+ return counts.road * RESOURCE_RULES.transportUpkeepSteel.road +
+ counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
},
// The road and railway lines of the budget breakdown, dropping any empty kind.
@@ -193,34 +273,34 @@ export const economyMethods = {
const counts = this._transportTiles(civ);
const sources = [];
if (counts.road > 0) {
+ const steel = counts.road * RESOURCE_RULES.transportUpkeepSteel.road;
sources.push({
label: TRANSPORT_BY_ID.road.name,
kind: "transport",
count: counts.road,
- amount: counts.road * TRANSPORT_BY_ID.road.maintenance,
+ resources: { steel, energy: 0, hightech: 0 },
+ amount: this._upkeepCost({ steel }),
});
}
if (counts.railway > 0) {
+ const steel = counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
sources.push({
label: TRANSPORT_BY_ID.railway.name,
kind: "transport",
count: counts.railway,
- amount: counts.railway * TRANSPORT_BY_ID.railway.maintenance,
+ resources: { steel, energy: 0, hightech: 0 },
+ amount: this._upkeepCost({ steel }),
});
}
return sources;
},
- getBuildingUpkeep(civ) {
- let total = 0;
- for (const city of this.cities) {
- if (city.civ !== civ) continue;
- for (const indexStr of Object.keys(city.buildings)) {
- const index = Number(indexStr);
- total += buildingUpkeep(this.protoBuildings[index], city.buildings[indexStr]);
- }
- }
- return total;
+ // The market value of a day's upkeep materials: what the state would pay to
+ // buy them, which is what the budget panel forecasts.
+ _upkeepCost(costs) {
+ return (costs.steel || 0) * this.getResourcePrice("steel") +
+ (costs.energy || 0) * this.getResourcePrice("energy") +
+ (costs.hightech || 0) * this.getResourcePrice("hightech");
},
getCityBuildingLevel(city, protoIndex) {
@@ -250,9 +330,9 @@ export const economyMethods = {
// civilisation's start equal; they scale the region figures too, so the
// regions of a nation always add up to its headline population and GDP.
const populationScale = this._populationBalance.get(city.civ) || 1;
- const perCapitaScale = this._perCapitaBalance.get(city.civ) || 1;
+ const productionScale = this._productionBalance.get(city.civ) || 1;
population *= populationScale;
- gdp *= populationScale * perCapitaScale;
+ gdp *= populationScale * productionScale;
const rate = ECONOMY.budgetGdpRate;
const base = gdp * rate;
const cityMods = this.getCityBuildingModifiers(city);
@@ -327,6 +407,14 @@ export const economyMethods = {
const tileImprovementSources = this._tileImprovementUpkeepSources(civ);
const tileImprovementUpkeep =
tileImprovementSources.reduce((sum, source) => sum + source.amount, 0);
+ // What the nation spent buying and earned selling each resource over the
+ // last day, for the budget panel's trade section.
+ const record = this.resourceSpend.get(civ) || {};
+ const buy = record.buy || {};
+ const sell = record.sell || {};
+ const resourceMoney = RESOURCE_IDS
+ .map((id) => ({ id, bought: buy[id] || 0, sold: sell[id] || 0 }))
+ .filter((entry) => entry.bought > 0 || entry.sold > 0);
return {
income: { rate, base, modifier, total: income, sources: incomeSources, regions },
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources, groups },
@@ -338,6 +426,7 @@ export const economyMethods = {
sources: tileImprovementSources,
groups: tileImprovementSources,
},
+ resourceMoney,
net: income - buildingUpkeep - unitUpkeep - campaignUpkeep - transportUpkeep - tileImprovementUpkeep,
};
},
@@ -392,13 +481,17 @@ export const economyMethods = {
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
const level = city.buildings[indexStr];
- const amount = buildingUpkeep(this.protoBuildings[index], level);
+ const proto = this.protoBuildings[index];
+ if (!proto || level <= 0) continue;
+ const resources = this.buildingUpkeepResources(proto, level);
+ const amount = this._upkeepCost(resources);
if (amount === 0) continue;
groups.push({
index,
- label: this.protoBuildings[index].name,
+ label: proto.name,
amount,
- cities: [{ cityId: city.id, cityName: city.name, level, amount }],
+ resources,
+ cities: [{ cityId: city.id, cityName: city.name, level, amount, resources }],
});
}
return groups.sort((a, b) => b.amount - a.amount);
@@ -465,16 +558,23 @@ export const economyMethods = {
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
const level = city.buildings[indexStr];
- const amount = buildingUpkeep(this.protoBuildings[index], level);
+ const proto = this.protoBuildings[index];
+ if (!proto || level <= 0) continue;
+ const resources = this.buildingUpkeepResources(proto, level);
+ const amount = this._upkeepCost(resources);
if (amount === 0) continue;
const group = byType.get(index) || {
index,
- label: this.protoBuildings[index].name,
+ label: proto.name,
amount: 0,
+ resources: { steel: 0, energy: 0, hightech: 0 },
cities: [],
};
group.amount += amount;
- group.cities.push({ cityId: city.id, cityName: city.name, level, amount });
+ group.resources.steel += resources.steel;
+ group.resources.energy += resources.energy;
+ group.resources.hightech += resources.hightech;
+ group.cities.push({ cityId: city.id, cityName: city.name, level, amount, resources });
byType.set(index, group);
}
}
@@ -488,6 +588,7 @@ export const economyMethods = {
label: group.label,
kind: "building",
amount: group.amount,
+ resources: group.resources,
}));
},
@@ -497,10 +598,27 @@ export const economyMethods = {
for (const unit of this.units) {
if (unit.civ !== civ) continue;
const proto = this.unitProto(unit);
- if (!proto || !proto.upkeep) continue;
+ if (!proto) continue;
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
- const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 };
- entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses);
+ const multiplier = this._statusUpkeepMultiplier(statuses);
+ const costs = this.unitUpkeepResources(proto);
+ const resources = {
+ steel: costs.steel * multiplier,
+ energy: 0,
+ hightech: costs.hightech * multiplier,
+ };
+ const amount = this._upkeepCost(resources);
+ if (amount === 0) continue;
+ const entry = sources.get(unit.proto) || {
+ label: proto.name,
+ kind: "unit",
+ amount: 0,
+ resources: { steel: 0, energy: 0, hightech: 0 },
+ count: 0,
+ };
+ entry.amount += amount;
+ entry.resources.steel += resources.steel;
+ entry.resources.hightech += resources.hightech;
entry.count += 1;
sources.set(unit.proto, entry);
}
@@ -604,6 +722,13 @@ export const economyMethods = {
return set ? set.has(index) : false;
},
+ // Whether a nation knows a technology, by its catalogue id. Used to gate the
+ // things only late research unlocks (the fusion plant).
+ hasTechnology(civ, id) {
+ const index = this.technologies.findIndex((tech) => tech.id === id);
+ return index >= 0 && this.isResearched(civ, index);
+ },
+
getResearchedIndices(civ) {
const set = this.researched.get(civ) || new Set();
return Array.from(set);
@@ -740,13 +865,38 @@ export const economyMethods = {
// Deducts `amount` from a nation's budget, returning false (and charging
// nothing) when it cannot afford it. A non-positive amount is always free.
- _spendBudget(civ, amount) {
+ // When a `category` is given the spend is filed into the month's budget
+ // ledger, so the panel can report what each kind of outlay cost.
+ // The government can always pay: a spend is never refused for want of money.
+ // A treasury driven below zero is an overdraft, a debt owed to the central
+ // bank, and carries interest until the budget recovers.
+ _spendBudget(civ, amount, category = null) {
if (amount <= 0) return true;
- if (this.getBudget(civ) < amount) return false;
this.budgets.set(civ, this.getBudget(civ) - amount);
+ // The money does not vanish: it is paid into the nation's regions, the
+ // private sector that supplied the goods, labour or services.
+ this._creditRegions(civ, amount);
+ if (category) this._recordBudgetCash(civ, category, -amount);
return true;
},
+ // How far a nation's treasury has been driven into the red, owed to its
+ // central bank.
+ getGovernmentDebt(civ) {
+ return Math.max(0, -this.getBudget(civ));
+ },
+
+ // Puts money back into the treasury, reversing a spend. With a category it
+ // cancels part of that category's month total, so a refunded order does not
+ // linger as an expense.
+ _refundBudget(civ, amount, category = null) {
+ if (!(amount > 0)) return;
+ this.budgets.set(civ, this.getBudget(civ) + amount);
+ // A refund claws the money back from the regions it was paid to.
+ this._chargeRegions(civ, amount);
+ if (category) this._recordBudgetCash(civ, category, amount);
+ },
+
getTraining(cityId) {
return this.training.get(cityId) || null;
},
@@ -762,12 +912,62 @@ export const economyMethods = {
this._populationVersion += 1;
},
+ // The people living in one city's region, summed over its tiles.
+ regionPopulation(city) {
+ let total = 0;
+ for (const coords of this.regionTiles(city)) {
+ total += this.tilePopulation.get(key(coords.x, coords.y)) || 0;
+ }
+ return total;
+ },
+
+ // Pulls `amount` people out of a city's region into a unit, choosing the
+ // tiles at random in proportion to how many people each holds. Returns how
+ // many were actually drawn (never more than the region had).
+ drawRegionPopulation(city, amount) {
+ if (!(amount > 0)) return 0;
+ const tiles = [];
+ let total = 0;
+ for (const coords of this.regionTiles(city)) {
+ const k = key(coords.x, coords.y);
+ const population = this.tilePopulation.get(k) || 0;
+ if (population <= 0) continue;
+ tiles.push({ k, population });
+ total += population;
+ }
+ if (total <= 0) return 0;
+ const wanted = Math.min(amount, total);
+ let remaining = wanted;
+ while (remaining > 0 && tiles.length > 0) {
+ let pick = this._random() * total;
+ let index = 0;
+ for (; index < tiles.length - 1; index++) {
+ pick -= tiles[index].population;
+ if (pick <= 0) break;
+ }
+ const tile = tiles[index];
+ const take = Math.min(tile.population, remaining);
+ tile.population -= take;
+ total -= take;
+ remaining -= take;
+ this.tilePopulation.set(tile.k, tile.population);
+ if (tile.population <= 0) tiles.splice(index, 1);
+ }
+ this._populationVersion += 1;
+ return wanted;
+ },
+
_tickEconomy() {
const hours = HOURS_PER_DAY;
for (const civ of Array.from(this.budgets.keys())) {
const income = this.getCivHourlyIncome(civ) * hours;
const upkeep = this.getPlayerUpkeep(civ) * hours;
- this.budgets.set(civ, this.getBudget(civ) + income - upkeep);
+ // Tax is collected from the private sector, not minted, then the day's
+ // propaganda spending is paid back out to it.
+ this._chargeRegions(civ, income);
+ this.budgets.set(civ, this.getBudget(civ) + income);
+ this._recordBudgetCash(civ, "income", income);
+ if (upkeep > 0) this._spendBudget(civ, upkeep, "campaign");
}
},
@@ -777,7 +977,12 @@ export const economyMethods = {
const scienceRate = modifiers[EFFECT_RESEARCH] || 0;
const cultureRate = modifiers[EFFECT_CULTURE] || 0;
if (scienceRate === 0 && cultureRate === 0) continue;
- this._addResearch(civ, scienceRate);
+ // Science buildings spend a unit of high-tech for each research point
+ // they make, so a nation short of chips researches only as far as its
+ // store reaches. Government research needs no high-tech.
+ const buildingRate = this.getCivBuildingModifiers(civ)[EFFECT_RESEARCH] || 0;
+ const effectiveBuilding = this._spendHighTechForResearch(civ, buildingRate);
+ this._addResearch(civ, scienceRate - buildingRate + effectiveBuilding);
this.culture.set(civ, this.getCulture(civ) + cultureRate);
}
},
@@ -877,13 +1082,55 @@ export const economyMethods = {
},
// Units resting inside one of their own cities recover a little health each
- // in-game hour.
+ // in-game hour, at a materials cost -- steel for a ground or naval unit, plus
+ // high-tech for an aircraft or radar -- and by drawing replacement soldiers
+ // out of the region's civilians. A nation that can find neither does not heal.
_tickGarrisonHealing() {
for (const unit of this.units) {
if (unit.hp >= unit.maxHp) continue;
const city = this.cityAt(unit.coords);
if (!city || city.civ !== unit.civ) continue;
- unit.hp = Math.min(unit.maxHp, unit.hp + CITY_GARRISON_HEAL_PER_HOUR);
+ const proto = this.unitProto(unit);
+ if (!proto) continue;
+ const heal = Math.min(unit.maxHp - unit.hp, CITY_GARRISON_HEAL_PER_HOUR);
+ if (heal <= 0) continue;
+ // Each hit point restored is a share of the unit's soldiers made good from
+ // the city's people; without enough people, only part of the heal happens.
+ const soldiers = proto.population || RESOURCE_RULES.unitPopulation;
+ const soldierNeed = soldiers > 0 ? (heal / unit.maxHp) * soldiers : 0;
+ const available = this.regionPopulation(city);
+ const popFraction = soldierNeed > 0 ? Math.min(1, available / soldierNeed) : 1;
+ const targetHeal = heal * popFraction;
+ if (targetHeal <= 0) continue;
+ const repair = RESOURCE_RULES.repair;
+ const wanted = {
+ steel: targetHeal * repair.unitSteelPerHp,
+ hightech: proto.advanced ? targetHeal * repair.unitHighTechPerHp : 0,
+ };
+ const fromStock = this._drawFromNearest(city.civ, city.coords, wanted);
+ const shortfall = {
+ steel: wanted.steel - fromStock.steel,
+ hightech: wanted.hightech - fromStock.hightech,
+ };
+ const cost = this._marketUnitCost("steel") * shortfall.steel +
+ this._marketUnitCost("hightech") * shortfall.hightech;
+ const steelCost = this._marketUnitCost("steel") * shortfall.steel;
+ const techCost = this._marketUnitCost("hightech") * shortfall.hightech;
+ const paid = 1;
+ if (cost > 0) {
+ // The government can always pay, borrowing if the treasury is dry.
+ this._spendBudget(city.civ, cost * paid, "units");
+ this._recordResourceSpend(city.civ, "upkeep", cost * paid, {
+ steel: steelCost * paid,
+ hightech: techCost * paid,
+ });
+ }
+ const gained = targetHeal * paid;
+ if (gained > 0) {
+ unit.hp = Math.min(unit.maxHp, unit.hp + gained);
+ const drawn = soldiers > 0 ? (gained / unit.maxHp) * soldiers : 0;
+ if (drawn > 0) this.drawRegionPopulation(city, drawn);
+ }
}
},
};
diff --git a/shared/game_state/money.js b/shared/game_state/money.js
new file mode 100644
index 0000000..508028e
--- /dev/null
+++ b/shared/game_state/money.js
@@ -0,0 +1,497 @@
+// Money and currencies. Each nation issues its own currency; a central bank
+// sets an interest rate and holds foreign reserves. The private sector is the
+// regions: every region keeps its own cash reserve, a basket of currencies, and
+// chases profit trading with its neighbours.
+//
+// Stage 1 keeps every currency at par (value 1 against the global unit) and
+// every region's cash in its own national currency. Later stages let the rates
+// float, fill the baskets with foreign money and move real trade onto private
+// cash; the shapes here (baskets, reserves, rates) are already the final ones.
+
+import { MONEY } from "../data/economy.js";
+import { DAYS_PER_YEAR } from "./constants.js";
+
+const FALLBACK_CURRENCY = { code: "XAU", symbol: "¤", name: "Credit" };
+
+function emptyBasket() {
+ return new Map();
+}
+
+export const moneyMethods = {
+ // ------------------------------------------------------- setup --
+
+ _initMoney() {
+ this.regionCash = new Map();
+ // What each region owes its national central bank, in the national currency.
+ this.regionDebt = new Map();
+ // What each nation's central bank owes foreign central banks, by currency:
+ // the offsetting side of the currency it issues when it settles trade in a
+ // foreign seller's money.
+ this.centralBankDebt = new Map();
+ this.centralBankRate = new Map();
+ this.centralBankReserves = new Map();
+ this.currencyValues = new Map();
+ this._currencyDemand = new Map();
+ this._currencyCivMap = null;
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ this.centralBankRate.set(civ, MONEY.defaultInterestRate);
+ this.centralBankReserves.set(civ, emptyBasket());
+ this.centralBankDebt.set(civ, emptyBasket());
+ this.currencyValues.set(civ, 1);
+ this._currencyDemand.set(civ, 0);
+ }
+ for (const city of this.cities) {
+ this.regionCash.set(city.id, this._openingRegionBasket(city));
+ }
+ },
+
+ // A region opens with a private-sector cash reserve in its nation's currency,
+ // sized off its yearly GDP.
+ _openingRegionBasket(city) {
+ const economy = this.getCityEconomy(city);
+ const cash = Math.max(0, economy.gdp) * MONEY.privateCashFraction;
+ return new Map([[this.currencyOf(city.civ).code, cash]]);
+ },
+
+ // ---------------------------------------------------- currencies --
+
+ currencyOf(civ) {
+ const civEntry = this.civilisations[civ];
+ return (civEntry && civEntry.currency) || FALLBACK_CURRENCY;
+ },
+
+ // The price of one unit of a nation's currency in global units. Every
+ // currency starts at par and floats from there.
+ currencyValue(civ) {
+ if (!this.currencyValues) return 1;
+ return this.currencyValues.get(civ) || 1;
+ },
+
+ setCurrencyValue(civ, value) {
+ const floor = 1 - MONEY.maxRateDrift;
+ const ceiling = 1 + MONEY.maxRateDrift;
+ this.currencyValues.set(civ, Math.max(floor, Math.min(ceiling, value)));
+ },
+
+ _civByCurrencyCode() {
+ if (!this._currencyCivMap) {
+ this._currencyCivMap = new Map();
+ this.civilisations.forEach((entry, index) => {
+ if (entry.currency) this._currencyCivMap.set(entry.currency.code, index);
+ });
+ }
+ return this._currencyCivMap;
+ },
+
+ currencyValueOf(code) {
+ const civ = this._civByCurrencyCode().get(code);
+ return civ === undefined ? 1 : this.currencyValue(civ);
+ },
+
+ // How many units of `toCiv`'s currency one unit of `fromCiv`'s buys.
+ exchangeRate(fromCiv, toCiv) {
+ if (fromCiv === toCiv) return 1;
+ return this.currencyValue(fromCiv) / this.currencyValue(toCiv);
+ },
+
+ // --------------------------------------------------- region cash --
+
+ // Takes `amount` of one currency out of a region's basket. Returns false (and
+ // charges nothing) when the region does not hold enough.
+ _debitRegionCash(city, code, amount) {
+ if (!(amount > 0)) return true;
+ const basket = this.regionCash ? this.regionCash.get(city.id) : null;
+ if (!basket) return false;
+ const held = basket.get(code) || 0;
+ if (held + 1e-9 < amount) return false;
+ basket.set(code, held - amount);
+ return true;
+ },
+
+ _creditRegionCash(city, code, amount) {
+ if (!(amount > 0)) return;
+ const basket = this.regionCash ? this.regionCash.get(city.id) : null;
+ if (!basket) return;
+ basket.set(code, (basket.get(code) || 0) + amount);
+ },
+
+ // Charges a region, spending its cash and borrowing whatever is missing from
+ // its national central bank at the bank's rate. A region never fails to pay:
+ // it takes on debt instead.
+ _chargeRegionCash(city, code, amount) {
+ if (!(amount > 0)) return true;
+ const basket = this.regionCash ? this.regionCash.get(city.id) : null;
+ if (!basket) return false;
+ const held = basket.get(code) || 0;
+ if (held >= amount) {
+ basket.set(code, held - amount);
+ return true;
+ }
+ basket.set(code, 0);
+ const shortfall = amount - held;
+ this.regionDebt.set(city.id, (this.regionDebt.get(city.id) || 0) + shortfall);
+ return true;
+ },
+
+ // Pays money into a nation's regions, split by population: the private sector
+ // is what ultimately receives government spending, so the money stays in the
+ // system rather than vanishing.
+ _creditRegions(civ, amount, code = null) {
+ if (!(amount > 0)) return;
+ const cities = this.cities.filter((city) => city.civ === civ);
+ if (cities.length === 0) return;
+ const currency = code || this.currencyOf(civ).code;
+ const shares = this._populationShares(cities);
+ for (let i = 0; i < cities.length; i++) {
+ this._creditRegionCash(cities[i], currency, amount * shares[i]);
+ }
+ },
+
+ // Takes money from a nation's regions, split by population, borrowing from the
+ // central bank where a region is short. This is how taxes are collected
+ // without minting money.
+ _chargeRegions(civ, amount) {
+ if (!(amount > 0)) return;
+ const cities = this.cities.filter((city) => city.civ === civ);
+ if (cities.length === 0) return;
+ const currency = this.currencyOf(civ).code;
+ const shares = this._populationShares(cities);
+ for (let i = 0; i < cities.length; i++) {
+ this._chargeRegionCash(cities[i], currency, amount * shares[i]);
+ }
+ },
+
+ // Pays the world market's producers -- every region outside the buyer's nation
+ // -- so an import does not drain money out of the world.
+ _payWorld(buyerCiv, amount, code) {
+ if (!(amount > 0)) return;
+ const others = this.cities.filter((city) => city.civ !== buyerCiv);
+ if (others.length === 0) {
+ this._creditRegions(buyerCiv, amount, code);
+ return;
+ }
+ const shares = this._populationShares(others);
+ for (let i = 0; i < others.length; i++) {
+ this._creditRegionCash(others[i], code, amount * shares[i]);
+ }
+ },
+
+ // Each region's share of a set's total population, defaulting to an even split
+ // when there are no people at all.
+ _populationShares(cities) {
+ let total = 0;
+ const populations = cities.map((city) => {
+ const people = this.regionPopulation(city);
+ total += people;
+ return people;
+ });
+ if (total <= 0) return cities.map(() => 1 / cities.length);
+ return populations.map((people) => people / total);
+ },
+
+ getRegionDebt(city) {
+ return this.regionDebt ? (this.regionDebt.get(city.id) || 0) : 0;
+ },
+
+ // Everything the nation's regions owe their central bank.
+ getRegionDebtTotal(civ) {
+ let total = 0;
+ for (const city of this.cities) {
+ if (city.civ === civ) total += this.getRegionDebt(city);
+ }
+ return total;
+ },
+
+ // The central bank's outstanding loans: the government's overdraft, every
+ // region's borrowing, and what it has issued to settle foreign trade.
+ getCentralBankLoans(civ) {
+ return this.getGovernmentDebt(civ) + this.getRegionDebtTotal(civ) +
+ this._centralBankDebtValue(civ);
+ },
+
+ // The city of `civ` nearest `coords`, so a sale to a resource building can be
+ // paid into the private cash of the region that owns it.
+ _regionCityNear(civ, coords) {
+ let best = null;
+ let bestDistance = Infinity;
+ for (const city of this.cities) {
+ if (city.civ !== civ) continue;
+ const distance = this.topology.tileDistance(city.coords, coords);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = city;
+ }
+ }
+ return best;
+ },
+
+ // --------------------------------------------------- central bank --
+
+ _addReserve(civ, code, amount) {
+ if (!(amount > 0)) return;
+ const reserves = this.centralBankReserves.get(civ)
+ || (this.centralBankReserves.set(civ, emptyBasket()), this.centralBankReserves.get(civ));
+ reserves.set(code, (reserves.get(code) || 0) + amount);
+ },
+
+ // The debt a central bank takes on when it issues its currency to settle a
+ // foreign seller's bill. It is the loan side of the money the sale created, so
+ // the world's money is conserved.
+ _addCentralBankDebt(civ, code, amount) {
+ if (!(amount > 0)) return;
+ let debts = this.centralBankDebt.get(civ);
+ if (!debts) {
+ debts = emptyBasket();
+ this.centralBankDebt.set(civ, debts);
+ }
+ debts.set(code, (debts.get(code) || 0) + amount);
+ },
+
+ getCentralBankDebt(civ) {
+ return this.centralBankDebt ? (this.centralBankDebt.get(civ) || emptyBasket()) : emptyBasket();
+ },
+
+ // What a central bank owes foreign banks, in its own currency.
+ _centralBankDebtValue(civ) {
+ const value = this.currencyValue(civ);
+ let total = 0;
+ for (const [code, amount] of this.getCentralBankDebt(civ)) {
+ total += amount * this.currencyValueOf(code) / value;
+ }
+ return total;
+ },
+
+ // A region buys `globalValue` (in global units) worth of `targetCiv`'s
+ // currency: first from what it already holds, then from that nation's central
+ // bank, paying with its own national currency (borrowing from its own bank if
+ // it is short). The target's central bank keeps the buyer's currency as a
+ // foreign reserve. Returns the amount of the target currency that ends up
+ // handed to the seller.
+ _regionAcquireCurrency(buyerCity, targetCiv, globalValue) {
+ if (!(globalValue > 0)) return { code: this.currencyOf(targetCiv).code, amount: 0 };
+ const target = this.currencyOf(targetCiv);
+ const buyer = this.currencyOf(buyerCity.civ);
+ const vTarget = this.currencyValue(targetCiv);
+ const vBuyer = this.currencyValue(buyerCity.civ);
+ const needTarget = globalValue / vTarget;
+ const basket = this.regionCash.get(buyerCity.id);
+ if (!basket) return null;
+ const heldTarget = basket.get(target.code) || 0;
+ const fromHeld = Math.min(heldTarget, needTarget);
+ const remaining = needTarget - fromHeld;
+ if (remaining > 0) {
+ const costBuyer = remaining * vTarget / vBuyer;
+ // The buyer pays with its national currency, borrowing the rest from its
+ // own central bank if it is short; the seller's bank holds that currency
+ // as a reserve and issues its own, taking on the matching debt so no money
+ // is conjured from nothing.
+ this._chargeRegionCash(buyerCity, buyer.code, costBuyer);
+ this._addReserve(targetCiv, buyer.code, costBuyer);
+ this._addCentralBankDebt(targetCiv, target.code, remaining);
+ }
+ if (fromHeld > 0) basket.set(target.code, heldTarget - fromHeld);
+ return { code: target.code, amount: fromHeld + remaining };
+ },
+
+ // --------------------------------------------------- demand/rates --
+
+ _recordCurrencyDemand(civ, amount) {
+ if (!this._currencyDemand || civ < 0 || !(amount !== 0)) return;
+ this._currencyDemand.set(civ, (this._currencyDemand.get(civ) || 0) + amount);
+ },
+
+ // All of a nation's currency in circulation, in global units: its own
+ // treasury, every region's holding and every central bank's reserve.
+ _currencyMass(civ) {
+ const code = this.currencyOf(civ).code;
+ const value = this.currencyValue(civ);
+ // An overdrawn treasury is a claim on the bank, not circulating money.
+ let amount = Math.max(0, this.getBudget(civ) || 0);
+ for (const city of this.cities) amount += this.getRegionCash(city).get(code) || 0;
+ for (const reserves of this.centralBankReserves.values()) {
+ amount += reserves.get(code) || 0;
+ }
+ return Math.max(1, amount * value);
+ },
+
+ // ----------------------------------------------------- holdings --
+
+ getRegionCash(city) {
+ if (!this.regionCash) return emptyBasket();
+ return this.regionCash.get(city.id) || emptyBasket();
+ },
+
+ getCentralBankReserves(civ) {
+ if (!this.centralBankReserves) return emptyBasket();
+ return this.centralBankReserves.get(civ) || emptyBasket();
+ },
+
+ getCentralBankRate(civ) {
+ if (!this.centralBankRate) return MONEY.defaultInterestRate;
+ const rate = this.centralBankRate.get(civ);
+ return rate === undefined ? MONEY.defaultInterestRate : rate;
+ },
+
+ setCentralBankRate(civ, rate) {
+ const value = Math.max(0, Math.min(MONEY.maxInterestRate, Number(rate) || 0));
+ this.centralBankRate.set(civ, value);
+ },
+
+ // A player sets the central bank's interest rate: the rate regions and the
+ // government borrow at, compounding on their debts.
+ requestSetInterestRate(civ, rate) {
+ if (civ < 0 || civ >= this.civilisations.length) return false;
+ const value = Number(rate);
+ if (!Number.isFinite(value) || value < 0) return false;
+ this.setCentralBankRate(civ, value);
+ this._emitChanged();
+ return true;
+ },
+
+ // The value of a basket in `inCiv`'s currency at the current rates.
+ _basketValue(basket, inCiv = 0) {
+ let total = 0;
+ const denom = this.currencyValue(inCiv);
+ for (const [code, amount] of basket) {
+ total += amount * this.currencyValueOf(code) / denom;
+ }
+ return total;
+ },
+
+ // The private-sector cash of one region, expressed in a nation's currency.
+ getRegionCashValue(city, inCiv = city.civ) {
+ return this._basketValue(this.getRegionCash(city), inCiv);
+ },
+
+ // Everything the private sector of a nation holds, in that nation's currency.
+ getPrivateSectorCash(civ, inCiv = civ) {
+ let total = 0;
+ for (const city of this.cities) {
+ if (city.civ !== civ) continue;
+ total += this._basketValue(this.getRegionCash(city), inCiv);
+ }
+ return total;
+ },
+
+ // --------------------------------------------------- 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.
+ _tickMoney() {
+ if (!this.regionCash) return;
+ for (const city of this.cities) {
+ const basket = this.regionCash.get(city.id);
+ const rate = this.getCentralBankRate(city.civ) / DAYS_PER_YEAR;
+ // Interest compounds on whatever the region already owes.
+ const debt = this.regionDebt.get(city.id) || 0;
+ if (debt > 0) this.regionDebt.set(city.id, debt * (1 + rate));
+ if (!basket) continue;
+ this._repatriateRegion(city, basket);
+ this._repayRegionDebt(city, basket);
+ }
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ // A treasury in the red is an overdraft that compounds too.
+ const budget = this.getBudget(civ);
+ if (budget < 0) {
+ const rate = this.getCentralBankRate(civ) / DAYS_PER_YEAR;
+ this.budgets.set(civ, budget * (1 + rate));
+ }
+ const demand = this._currencyDemand.get(civ) || 0;
+ const mass = this._currencyMass(civ);
+ const value = this.currencyValue(civ) * (1 + MONEY.demandSensitivity * (demand / mass));
+ this.setCurrencyValue(civ, value);
+ this._currencyDemand.set(civ, 0);
+ }
+ },
+
+ // A region uses any cash it has to pay down its central-bank debt, so the
+ // interest does not run away.
+ _repayRegionDebt(city, basket) {
+ const debt = this.regionDebt.get(city.id) || 0;
+ if (!(debt > 0)) return;
+ const code = this.currencyOf(city.civ).code;
+ const cash = basket.get(code) || 0;
+ const repay = Math.min(cash, debt);
+ if (!(repay > 0)) return;
+ basket.set(code, cash - repay);
+ this.regionDebt.set(city.id, debt - repay);
+ },
+
+ // A region turns a slice of every foreign currency it holds back into its own;
+ // the private sector prefers to carry national money. The conversion is priced
+ // through the global desk at today's rates.
+ _repatriateRegion(city, basket) {
+ const national = this.currencyOf(city.civ).code;
+ let changed = false;
+ for (const [code, amount] of Array.from(basket)) {
+ if (code === national || !(amount > 0)) continue;
+ const move = amount * MONEY.repatriationPerDay;
+ if (!(move > 0)) continue;
+ const value = move * this.currencyValueOf(code);
+ basket.set(code, amount - move);
+ const issuer = this._civByCurrencyCode().get(code);
+ if (issuer !== undefined) this._recordCurrencyDemand(issuer, -value);
+ this._recordCurrencyDemand(city.civ, value);
+ this._creditRegionCash(city, national, value / this.currencyValue(city.civ));
+ changed = true;
+ }
+ if (changed) this.regionCash.set(city.id, basket);
+ },
+
+ // ------------------------------------------------- serialization --
+
+ _serializeCurrencies() {
+ return this.civilisations.map((_, civ) => {
+ const currency = this.currencyOf(civ);
+ return {
+ civ,
+ code: currency.code,
+ symbol: currency.symbol,
+ name: currency.name,
+ value: this.currencyValue(civ),
+ };
+ });
+ },
+
+ // The exchange rate from the viewer's currency to every other nation's, for
+ // the national Economy tab.
+ _serializeExchangeRates(viewerCiv) {
+ return this.civilisations.map((_, civ) => {
+ const currency = this.currencyOf(civ);
+ return {
+ civ,
+ code: currency.code,
+ symbol: currency.symbol,
+ name: currency.name,
+ rate: this.exchangeRate(viewerCiv, civ),
+ };
+ });
+ },
+
+ _serializeCentralBank(civ) {
+ const reserves = [];
+ for (const [code, amount] of this.getCentralBankReserves(civ)) {
+ if (!(amount > 0)) continue;
+ const issuer = this._civByCurrencyCode().get(code);
+ const currency = issuer === undefined ? FALLBACK_CURRENCY : this.currencyOf(issuer);
+ reserves.push({ code, symbol: currency.symbol, name: currency.name, amount });
+ }
+ return {
+ rate: this.getCentralBankRate(civ),
+ currency: this.currencyOf(civ),
+ reserves,
+ // What the bank has lent: the government's overdraft plus region borrowing.
+ loans: this.getCentralBankLoans(civ),
+ };
+ },
+
+ _serializeRegionCash(city) {
+ const cash = {};
+ for (const [code, amount] of this.getRegionCash(city)) {
+ if (amount > 0) cash[code] = amount;
+ }
+ return cash;
+ },
+};
diff --git a/shared/game_state/monthly.js b/shared/game_state/monthly.js
index fdd5898..a92706d 100644
--- a/shared/game_state/monthly.js
+++ b/shared/game_state/monthly.js
@@ -25,12 +25,15 @@ export const monthlyMethods = {
// the same month a year earlier. A rolling 13 months is enough to always
// hold the twelve-months-ago baseline.
this._monthStartHistory = [{ index: this._monthIndexNow, stats: this._currentMonthStats }];
+ // The monthly budget report starts its first ledger here too.
+ this._initBudgetHistory();
},
// Called every hour; only a calendar-month change does any work.
_tickMonthly() {
const index = this._monthIndexOf(this.totalHours);
if (index === this._monthIndexNow) return;
+ const previous = this._monthIndexNow;
// What was current at the start of the month that just ended is now the
// baseline for the new month.
this.monthlyStats = this._currentMonthStats;
@@ -38,6 +41,8 @@ export const monthlyMethods = {
this._monthIndexNow = index;
this._monthStartHistory.push({ index, stats: this._currentMonthStats });
if (this._monthStartHistory.length > 13) this._monthStartHistory.shift();
+ // The month that just ended becomes a browsable budget digest.
+ this._rolloverBudget(previous);
},
// The relative growth of a nation's headline figures against the same month a
diff --git a/shared/game_state/orders.js b/shared/game_state/orders.js
index e5c9c75..1d5eb09 100644
--- a/shared/game_state/orders.js
+++ b/shared/game_state/orders.js
@@ -7,6 +7,7 @@ import { GOVERNMENTS } from "../data/governments.js";
import { PILLAGE, pillageSteps } from "../data/combat.js";
import { transportImprovement } from "../data/improvements.js";
import { buildingBuildCost, buildingRefund } from "../rules.js";
+import { tileImprovementResourceCost } from "../resources.js";
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
export const orderMethods = {
@@ -39,7 +40,12 @@ export const orderMethods = {
}
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
if (capacity <= 0) return false;
- if (!this._spendBudget(city.civ, proto.cost)) return false;
+ if (!this._spendBudget(city.civ, proto.cost, "training")) return false;
+ const materials = this.constructionResourceCost(proto, 0, proto.cost);
+ if (!this._payConstructionResources(city.civ, city.coords, materials, "training")) {
+ this._refundBudget(city.civ, proto.cost, "training");
+ return false;
+ }
const entry = {
kind: "unit",
protoIndex,
@@ -79,6 +85,7 @@ export const orderMethods = {
// Placement rules: a port needs the sea, a shipyard needs a port.
if (proto.coastal && !this.isCoastalCity(city)) return false;
if (proto.requiresBuilding && !this.hasCityBuilding(city, proto.requiresBuilding)) return false;
+ if (proto.requiresTechnology && !this.hasTechnology(city.civ, proto.requiresTechnology)) return false;
// The local testing shortcut: raise the next level at once, for free.
if (free) {
const level =
@@ -106,7 +113,12 @@ export const orderMethods = {
}
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
if (capacity <= 0) return false;
- if (!this._spendBudget(city.civ, cost)) return false;
+ if (!this._spendBudget(city.civ, cost, "construction")) return false;
+ const materials = this.constructionResourceCost(proto, level, cost);
+ if (!this._payConstructionResources(city.civ, city.coords, materials)) {
+ this._refundBudget(city.civ, cost, "construction");
+ return false;
+ }
const entry = {
kind: "building",
protoIndex,
@@ -139,7 +151,14 @@ export const orderMethods = {
if (this.civAt(coords) !== civ) return false;
const current = this.railways.has(k) ? "railway" : this.roads.has(k) ? "road" : null;
if (current === improvement.id) return false;
- if (!free && !this._spendBudget(civ, improvement.buildCost)) return false;
+ if (!free) {
+ if (!this._spendBudget(civ, improvement.buildCost, "tilebuild")) return false;
+ const materials = tileImprovementResourceCost(improvement);
+ if (!this._payConstructionResources(civ, coords, materials, "tilebuild")) {
+ this._refundBudget(civ, improvement.buildCost, "tilebuild");
+ return false;
+ }
+ }
this.roads.delete(k);
this.railways.delete(k);
if (improvement.id === "railway") this.railways.add(k);
@@ -177,7 +196,7 @@ export const orderMethods = {
const level = this.getCityBuildingLevel(city, protoIndex);
if (level <= 0) return false;
const refund = buildingRefund(proto, level);
- if (refund > 0) this.budgets.set(civ, this.getBudget(civ) + refund);
+ if (refund > 0) this._refundBudget(civ, refund, "construction");
if (level <= 1) delete city.buildings[protoIndex];
else city.buildings[protoIndex] = level - 1;
this._gdpPerCapitaCache.clear();
@@ -192,8 +211,7 @@ export const orderMethods = {
if (this.getGovernment(civ) === index) return false;
const cost = GOVERNMENTS[index].baseCost +
GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0);
- if (cost > 0 && this.getBudget(civ) < cost) return false;
- if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
+ if (cost > 0) this._spendBudget(civ, cost, "campaign");
this.government.set(civ, index);
this._gdpPerCapitaCache.clear();
this._touchModifiers();
@@ -232,8 +250,8 @@ export const orderMethods = {
// Pillages the tile the units stand on. Every unit in the stack takes a turn,
// the weakest first (least HP relative to maximum): each removes 10% of the
- // remaining population, cuts the tile's GDP per capita by 30% and heals one
- // hit point per 1000 people it drives off.
+ // remaining population, cuts the tile's production per head by 30% and heals
+ // one hit point per 1000 people it drives off.
requestPillage(unitIds) {
const units = this._unitsForOrder(unitIds);
if (!this.canPillage(unitIds)) return false;
@@ -243,10 +261,12 @@ export const orderMethods = {
const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp);
let population = this.tilePopulation.get(k) || 0;
const { steps } = pillageSteps(population, ordered.length);
- let penalty = this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1;
+ let penalty = this.tileProductionPenalty.get(k) !== undefined
+ ? this.tileProductionPenalty.get(k)
+ : 1;
let drivenOff = 0;
for (let i = 0; i < ordered.length; i++) {
- penalty *= PILLAGE.gdpPenalty;
+ penalty *= PILLAGE.productionPenalty;
const unit = ordered[i];
unit.hp = Math.min(unit.maxHp, unit.hp + steps[i].heal);
drivenOff += steps[i].removed;
@@ -254,7 +274,7 @@ export const orderMethods = {
population = steps.length > 0 ? steps[steps.length - 1].remaining : population;
this.tilePopulation.set(k, population);
this._populationVersion += 1;
- this.tileGdpPenalty.set(k, penalty);
+ this.tileProductionPenalty.set(k, penalty);
this._gdpPerCapitaCache.delete(k);
// The people driven off the tile are civilians lost to its owner in the war
// against the pillaging civilisation.
diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js
index 5cf2951..b5ce808 100644
--- a/shared/game_state/politics.js
+++ b/shared/game_state/politics.js
@@ -61,6 +61,7 @@ export const politicsMethods = {
_tickPolitics() {
this._tickCampaigns();
this._tickMigration();
+ this._migrateForIncome();
this._tickUprisings();
this._tickCultureImpact();
this._expireGrudges();
@@ -672,6 +673,280 @@ export const politicsMethods = {
this._migrateByAir();
},
+ // ------------------------------------------------- income migration --
+
+ // People drift from poorer regions to richer ones, but only to places clearly
+ // richer: a destination must be at least `incomeRatioBase` times the source's
+ // income per head, and the bar rises the longer the journey takes. A flight
+ // crosses the distance in a flat few hours, but each airport can only move so
+ // many people a day.
+ _migrateForIncome() {
+ if (this.cities.length < 2) return;
+ const income = this._regionIncomePerCapita(this.cities);
+ // Each airport's daily intake, shared among every region that flies into it.
+ const airBudget = new Map();
+ for (const city of this.cities) {
+ const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_IMMIGRATION);
+ if (level > 0) airBudget.set(city.id, MIGRATION.airPeoplePerDayPerLevel * level);
+ }
+ for (const from of this.cities) {
+ const fromIncome = income.get(from.id) || 0;
+ if (fromIncome <= 0) continue;
+ let source = this.regionPopulation(from) - MIGRATION.minRegionPopulation;
+ if (source <= 0) continue;
+ // The time bar only ever raises the requirement above the base ratio, so a
+ // region with no destination that much richer has nowhere to send anyone.
+ // Skip the route search entirely -- the common, balanced case.
+ const worthwhile = this.cities.some((other) =>
+ other !== from &&
+ (income.get(other.id) || 0) >= fromIncome * MIGRATION.incomeRatioBase
+ );
+ if (!worthwhile) continue;
+ const fromAirport = this._cityMechanicLevel(from, BUILDING_MECHANIC.AIR_IMMIGRATION);
+ const travel = this._migrationTravel(from);
+ for (const to of this.cities) {
+ if (to === from || source <= 0) continue;
+ const toIncome = income.get(to.id) || 0;
+ if (toIncome <= 0) continue;
+ // Two nations at war do not trade people.
+ if (this.isAtWar(from.civ, to.civ)) continue;
+ const toAirport = this._cityMechanicLevel(to, BUILDING_MECHANIC.AIR_IMMIGRATION);
+ const air = fromAirport > 0 && toAirport > 0;
+ const hours = air ? MIGRATION.airHours : travel.get(to.id);
+ if (hours === undefined) continue;
+ // The longer it takes, the richer the destination has to be.
+ const required = MIGRATION.incomeRatioBase * (1 + hours * MIGRATION.incomeTimePenalty);
+ const ratio = toIncome / fromIncome;
+ if (ratio < required) continue;
+ let amount;
+ if (air) {
+ const budget = airBudget.get(to.id) || 0;
+ amount = Math.min(budget, source);
+ if (amount <= 0) continue;
+ airBudget.set(to.id, budget - amount);
+ } else {
+ const rate = Math.min(
+ MIGRATION.incomeMaxSharePerDay,
+ MIGRATION.incomeSharePerDay * Math.min(ratio - required, MIGRATION.incomeGapCap)
+ );
+ amount = source * rate;
+ }
+ amount = Math.min(amount, source);
+ if (!(amount > 0)) continue;
+ this._movePopulation(from, to, amount);
+ source -= amount;
+ }
+ }
+ },
+
+ // The travel time in hours from one region to every city it can reach over the
+ // transport network, capped at `maxTravelHours`. Land is walked (roads and
+ // railways are faster), open water is crossed at the sea rate.
+ _migrationTravel(fromCity) {
+ // Travel times depend only on the transport network, so they are kept until
+ // a road, railway or port changes it.
+ if (!this._travelCache) this._travelCache = new Map();
+ const cached = this._travelCache.get(fromCity.id);
+ if (cached && cached.version === this._improvementVersion) return cached.result;
+ const hours = new Map();
+ const result = new Map();
+ const startKey = key(fromCity.coords.x, fromCity.coords.y);
+ hours.set(startKey, 0);
+ const queue = [fromCity.coords];
+ let head = 0;
+ while (head < queue.length) {
+ const coords = queue[head++];
+ const k = key(coords.x, coords.y);
+ const here = hours.get(k) || 0;
+ const city = this.cityAt(coords);
+ if (city && city !== fromCity) result.set(city.id, here);
+ if (here >= MIGRATION.maxTravelHours) continue;
+ for (const neighbour of this._neighbours(coords)) {
+ const nk = key(neighbour.x, neighbour.y);
+ if (hours.has(nk) || !this.tiles[nk]) continue;
+ const step = this._peopleHoursPerTile(neighbour);
+ if (!Number.isFinite(step)) continue;
+ hours.set(nk, here + step);
+ queue.push(neighbour);
+ }
+ }
+ this._travelCache.set(fromCity.id, { version: this._improvementVersion, result });
+ return result;
+ },
+
+ // What one tile costs a migrant in hours: open water is a flat sea rate, land
+ // is the terrain (or road/railway) multiplier over the walking speed.
+ _peopleHoursPerTile(coords) {
+ const tile = this.tiles[key(coords.x, coords.y)];
+ if (!tile) return Infinity;
+ if (tile.terrainClass === "Sea" || tile.terrainClass === "Ice") {
+ return MIGRATION.seaHoursPerTile;
+ }
+ return this._tileMovementMultiplier(coords, -1) / MIGRATION.peopleSpeed;
+ },
+
+ // The production per head of every region, as the economy shows it. The
+ // nation-wide balance is folded in here too, so two regions in different
+ // countries compare on the same footing.
+ _regionIncomePerCapita(cities) {
+ const result = new Map();
+ for (const city of cities) {
+ const economy = this.getCityEconomy(city);
+ result.set(city.id, economy.population > 0 ? economy.gdp / economy.population : 0);
+ }
+ return result;
+ },
+
+ // Moves `amount` people from one region to another, carrying their ethnic
+ // make-up with them. Source tiles lose people in proportion to what they hold
+ // and destination tiles gain the arrivals the same way, so a region's mix of
+ // terrain is preserved and only its density changes. Returns how many moved.
+ _movePopulation(fromCity, toCity, amount, record = true) {
+ if (!(amount > 0) || fromCity === toCity) return 0;
+ const source = this.regionTiles(fromCity)
+ .map((coords) => ({ coords, k: key(coords.x, coords.y) }))
+ .filter((entry) => (this.tilePopulation.get(entry.k) || 0) > 0);
+ const dest = this.regionTiles(toCity)
+ .map((coords) => ({ coords, k: key(coords.x, coords.y) }))
+ .filter((entry) => (this.tilePopulation.get(entry.k) || 0) > 0);
+ if (source.length === 0 || dest.length === 0) return 0;
+ const totalFrom = source.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
+ if (totalFrom <= 0) return 0;
+ const moved = Math.min(amount, totalFrom);
+ if (!(moved > 0)) return 0;
+
+ // The aggregate ethnic mix of the people leaving.
+ const mix = new Map();
+ for (const entry of source) {
+ const people = this.tilePopulation.get(entry.k) || 0;
+ const shares = this.tileEthnicity.get(entry.k);
+ if (!shares) continue;
+ for (const [eth, share] of shares) mix.set(eth, (mix.get(eth) || 0) + share * people);
+ }
+ const totalMix = Array.from(mix.values()).reduce((sum, value) => sum + value, 0) || 1;
+ for (const [eth, value] of mix) mix.set(eth, value / totalMix);
+
+ for (const entry of source) {
+ const people = this.tilePopulation.get(entry.k) || 0;
+ this.tilePopulation.set(entry.k, people - moved * people / totalFrom);
+ }
+ const totalTo = dest.reduce((sum, entry) => sum + this.tilePopulation.get(entry.k), 0);
+ for (const entry of dest) {
+ const people = this.tilePopulation.get(entry.k) || 0;
+ const add = totalTo > 0 ? moved * people / totalTo : moved / dest.length;
+ const after = people + add;
+ this.tilePopulation.set(entry.k, after);
+ const shares = new Map(this.tileEthnicity.get(entry.k) || []);
+ for (const [eth, share] of mix) {
+ const existing = shares.get(eth) || 0;
+ shares.set(eth, (existing * people + share * add) / after);
+ }
+ this._setTileEthnicFractions(parseKey(entry.k), shares);
+ }
+ this._populationVersion += 1;
+ this._gdpPerCapitaCache.clear();
+ // Keep each city's headline population in step with its city tile.
+ for (const city of [fromCity, toCity]) {
+ city.population = Math.round(
+ this.tilePopulation.get(key(city.coords.x, city.coords.y)) || 0
+ );
+ }
+ if (record) {
+ for (const [eth, share] of mix) {
+ const people = moved * share;
+ this._recordMigration(fromCity.civ, "out", eth, people);
+ this._recordMigration(toCity.civ, "in", eth, people);
+ this._trackMigration(
+ key(fromCity.coords.x, fromCity.coords.y),
+ key(toCity.coords.x, toCity.coords.y),
+ eth,
+ people
+ );
+ }
+ }
+ return moved;
+ },
+
+ // Start-of-game smoothing: within each nation, redistribute people so every
+ // region has the same production per head. A region's GDP does not depend on
+ // how many people live on its land -- food comes from the land and industry
+ // from its works -- so moving people in proportion leaves every region's GDP
+ // untouched and only changes the divisor. Equal income per head therefore
+ // means population proportional to GDP, which is one exact pass, no iteration.
+ _equalizeRegionIncomes() {
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ const cities = this.cities.filter((city) => city.civ === civ);
+ if (cities.length < 2) continue;
+ // The population-proportional-to-GDP target is exact only while a region's
+ // GDP is independent of its people; flat per-head bonuses (ports, natural
+ // growth) make it depend a little, so a few passes settle it.
+ for (let pass = 0; pass < MIGRATION.equalizePasses; pass++) {
+ const population = new Map();
+ const gdp = new Map();
+ let totalPopulation = 0;
+ let totalGdp = 0;
+ let lowest = Infinity;
+ let highest = 0;
+ for (const city of cities) {
+ const people = this.regionPopulation(city);
+ const product = this.getCityEconomy(city).gdp;
+ population.set(city.id, people);
+ gdp.set(city.id, product);
+ totalPopulation += people;
+ totalGdp += product;
+ if (people > 0) {
+ const perHead = product / people;
+ if (perHead < lowest) lowest = perHead;
+ if (perHead > highest) highest = perHead;
+ }
+ }
+ if (totalGdp <= 0 || totalPopulation <= 0) break;
+ if (highest <= 0 || (highest - lowest) / highest <= MIGRATION.equalizeTolerance) {
+ break;
+ }
+ // The share of the nation's people each region should hold, with a floor
+ // so no region is emptied entirely.
+ let target = cities.map((city) => Math.max(
+ MIGRATION.minRegionPopulation,
+ totalPopulation * (gdp.get(city.id) || 0) / totalGdp
+ ));
+ const targetTotal = target.reduce((sum, value) => sum + value, 0);
+ if (targetTotal > totalPopulation) {
+ target = target.map((value) => value * totalPopulation / targetTotal);
+ }
+ const targetByCity = new Map(cities.map((city, index) => [city.id, target[index]]));
+ this._redistributePopulation(cities, population, targetByCity);
+ }
+ }
+ this._gdpPerCapitaCache.clear();
+ },
+
+ // Moves people from the regions holding more than their target to those
+ // holding less, so each ends at its target.
+ _redistributePopulation(cities, population, target) {
+ const surplus = [];
+ const deficit = [];
+ for (const city of cities) {
+ const delta = (population.get(city.id) || 0) - (target.get(city.id) || 0);
+ if (delta > 1) surplus.push({ city, amount: delta });
+ else if (delta < -1) deficit.push({ city, amount: -delta });
+ }
+ surplus.sort((a, b) => b.amount - a.amount);
+ deficit.sort((a, b) => b.amount - a.amount);
+ let index = 0;
+ for (const source of surplus) {
+ let remaining = source.amount;
+ while (remaining > 1 && index < deficit.length) {
+ const sink = deficit[index];
+ const moved = Math.min(remaining, sink.amount);
+ if (moved > 1) this._movePopulation(source.city, sink.city, moved, false);
+ remaining -= moved;
+ sink.amount -= moved;
+ if (sink.amount <= 1) index += 1;
+ }
+ }
+ },
+
// People fly between airport cities. Every city with an airport draws a small
// share of every other airport city's people, scaled by its own airport level,
// so a busy hub grows at the expense of quieter ones. The move ignores
diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js
new file mode 100644
index 0000000..a503b37
--- /dev/null
+++ b/shared/game_state/resources.js
@@ -0,0 +1,1692 @@
+// Resources: the five material groups nations produce, store, trade and
+// consume. Storage lives on the map -- a city tile or a resource-building tile
+// holds its own stock -- while energy is a flow: power plants feed the grid of
+// their connected region, and a shortfall is bought and delivered for money.
+//
+// The daily tick is the heart of it: produce, satisfy electric demand, consume
+// what the people need, buy the rest from neighbours and finally from the
+// global market, then let shortages bite and prices drift.
+
+import { key, parseKey } from "../hex.js";
+import {
+ RESOURCE_IDS,
+ STORABLE_RESOURCE_IDS,
+ RESOURCE_RULES,
+ RESOURCE_MARKET_BASE,
+ RESOURCE_TRADE_RADIUS,
+ tileImprovementById,
+ isResourceTileBuilding,
+ MONEY,
+} from "../data.js";
+import {
+ cityEnergyPerDay,
+ foodNeedPerDay,
+ tileFoodOutput,
+ steelNeedPerDay,
+ luxuryNeedPerDay,
+ foodMonthlyTarget,
+ producerEnergyPerDay,
+ deliveryEnergyForResource,
+ nextMarketPrice,
+ upkeepResources,
+ protoUpkeep,
+ constructionResourceCost as sharedConstructionResourceCost,
+} from "../resources.js";
+import { buildingBuildCost } from "../rules.js";
+import { Random } from "../rng.js";
+import { TILE_IMPROVEMENT_HP } from "./constants.js";
+
+// A city keeps at least this many days of a material in stock before it will
+// sell any of it on; the rest is surplus a neighbour may buy.
+const SUPPLIER_RESERVE_DAYS = 2;
+
+// The producers a fresh nation may be seeded with. Energy uses the large
+// fossil plants so a nation needs few works to light its industry; the luxury
+// pick is randomised between the two mines. A producer locked behind research
+// (fusion, and the chip foundry, which no opening need calls for) never
+// appears.
+const SEED_PRODUCERS = {
+ steel: ["steel_mill"],
+ luxury: ["diamond_mine", "gold_mine"],
+ hightech: ["chip_foundry"],
+ energy: ["coal_power_plant", "natural_gas_power_plant", "oil_power_plant"],
+};
+// How many top-up passes the start-of-game seeding makes before it gives up on
+// the tiles that are left. Each pass places everything a resource is short by,
+// so three or four passes settle a nation.
+const SEED_MAX_PASSES = 10;
+// A little headroom, so the opening economy is not balanced on a knife edge as
+// prices and population drift in the first days. Power gets the larger cushion
+// because it scales with GDP, which the market moves.
+const SEED_MATERIAL_MARGIN = 1.05;
+const SEED_ENERGY_MARGIN = 1.15;
+
+function emptyStock() {
+ return { steel: 0, food: 0, luxury: 0, hightech: 0 };
+}
+
+export const resourceMethods = {
+ // -------------------------------------------------------- setup/state --
+
+ _initResources() {
+ this.resourceStock = new Map();
+ this.resourcePrices = new Map();
+ this.resourceMarketStats = new Map();
+ for (const id of RESOURCE_IDS) {
+ this.resourcePrices.set(id, RESOURCE_MARKET_BASE[id]);
+ this.resourceMarketStats.set(id, { supply: 0, demand: 0, price: RESOURCE_MARKET_BASE[id] });
+ }
+ this.resourceLinks = [];
+ this._resourceShortages = new Map();
+ this._resourceVersion = 0;
+ // What each nation imported and exported over the last simulated day, by
+ // resource, from cross-nation deliveries only.
+ this.resourceTrade = new Map();
+ // What each nation spent on resources during the current day, split into
+ // electric imports, bought goods, transport, construction and upkeep.
+ this.resourceSpend = new Map();
+ // Repair state. City building levels knocked down by bombardment wait here
+ // to be rebuilt once their materials are paid; a damaged tile work carries a
+ // steel debt that must clear before it heals or its GDP recovers.
+ this.cityRepairQueue = new Map();
+ this.tileRepairDebt = new Map();
+ for (const city of this.cities) this._seedCityResources(city);
+ },
+
+ // Records money spent on resources. `resource` may name the one commodity the
+ // bill was for, or be a `{ id: money }` map when several were bought at once,
+ // so the budget panel can show a per-resource breakdown.
+ _recordResourceSpend(civ, kind, amount, resource = null) {
+ if (!(amount > 0)) return;
+ const record = this._resourceRecord(civ);
+ record[kind] = (record[kind] || 0) + amount;
+ if (typeof resource === "string") {
+ record.buy[resource] = (record.buy[resource] || 0) + amount;
+ this._recordBudgetResource(civ, resource, amount, 0);
+ } else if (resource) {
+ for (const [id, value] of Object.entries(resource)) {
+ if (value > 0) {
+ record.buy[id] = (record.buy[id] || 0) + value;
+ this._recordBudgetResource(civ, id, value, 0);
+ }
+ }
+ }
+ },
+
+ // Records money earned selling a resource to another country.
+ _recordResourceEarn(civ, resource, amount) {
+ if (!(amount > 0)) return;
+ const record = this._resourceRecord(civ);
+ record.sell[resource] = (record.sell[resource] || 0) + amount;
+ this._recordBudgetResource(civ, resource, 0, amount);
+ },
+
+ _resourceRecord(civ) {
+ let record = this.resourceSpend.get(civ);
+ if (!record) {
+ record = {
+ energy: 0, goods: 0, transport: 0, construction: 0, upkeep: 0, combat: 0,
+ buy: {}, sell: {},
+ };
+ this.resourceSpend.set(civ, record);
+ }
+ if (!record.buy) record.buy = {};
+ if (!record.sell) record.sell = {};
+ return record;
+ },
+
+ // Every city opens with most of a month of food, a little steel and luxury,
+ // and some high-tech, so an early game with no producer yet can still build.
+ _seedCityResources(city) {
+ const k = key(city.coords.x, city.coords.y);
+ const stock = emptyStock();
+ stock.food = foodMonthlyTarget(city.population) * 0.75;
+ stock.steel = steelNeedPerDay(city.population) * 45;
+ stock.luxury = luxuryNeedPerDay(city.population) * 45;
+ stock.hightech = 500;
+ this.resourceStock.set(k, stock);
+ },
+
+ _ensureResourceStock(k) {
+ let stock = this.resourceStock.get(k);
+ if (!stock) {
+ stock = emptyStock();
+ this.resourceStock.set(k, stock);
+ }
+ return stock;
+ },
+
+ // ------------------------------------------------------ starting industry --
+
+ // Lays the resource works a nation opens the game with, so a fresh economy
+ // already makes what its people and its industry need. Called during world
+ // generation, after the world and its balanced economy exist but before the
+ // budgets are founded, so the opening treasury reflects the works. It draws
+ // from the world seed, so a game replayed from the same seed lays out the
+ // same map. A nation short of land (a one-city island) gets as many works as
+ // fit and trades for the rest.
+ _seedResourceBuildings() {
+ const rng = new Random((this.seed ^ 0x27d4eb2f) >>> 0);
+ const components = this._resourceLandComponents();
+ // A work only counts when its land component holds one of the nation's
+ // cities: otherwise its grid has no demand and its output no store to feed.
+ const componentCivs = new Map();
+ for (const city of this.cities) {
+ const component = components.get(key(city.coords.x, city.coords.y));
+ if (component === undefined) continue;
+ if (!componentCivs.has(component)) componentCivs.set(component, new Set());
+ componentCivs.get(component).add(city.civ);
+ }
+ // The food the land grows is fixed for the whole pass, so charge its energy
+ // once. Ports lift it, so the region multiplier is folded in here.
+ const foodEnergy = this._foodEnergyByComponent(components);
+ const isNetwork = (k) => this.roads.has(k) || this.railways.has(k);
+ const touchesNetwork = (coords) => {
+ for (const neighbour of this._neighbours(coords)) {
+ if (isNetwork(key(neighbour.x, neighbour.y))) return true;
+ }
+ return false;
+ };
+ const claims = [];
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ // Tiles are ranked so the works land where the transport network can
+ // reach them: on the network first, then beside it (a one-tile spur
+ // connects them), and only then off it.
+ const tiers = [[], [], []];
+ const tiersByComponent = new Map();
+ for (const coords of this._territoryByCiv.get(civ) || []) {
+ const k = key(coords.x, coords.y);
+ if (this.cityAt(coords) || this.tileImprovements.has(k)) continue;
+ const component = components.get(k);
+ if (component === undefined || !componentCivs.get(component)?.has(civ)) continue;
+ const tier = isNetwork(k) ? 0 : touchesNetwork(coords) ? 1 : 2;
+ tiers[tier].push(coords);
+ if (!tiersByComponent.has(component)) tiersByComponent.set(component, [[], [], []]);
+ tiersByComponent.get(component)[tier].push(coords);
+ }
+ for (const list of tiers) rng.shuffle(list);
+ for (const lists of tiersByComponent.values()) for (const list of lists) rng.shuffle(list);
+ const spots = tiers[0].concat(tiers[1], tiers[2]);
+ const byComponent = new Map();
+ for (const [component, lists] of tiersByComponent) {
+ byComponent.set(component, lists[0].concat(lists[1], lists[2]));
+ }
+ claims.push({ civ, spots, byComponent });
+ }
+ for (const claim of claims) {
+ this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
+ }
+ this._gdpPerCapitaCache.clear();
+ },
+
+ // The greedy heart of the seeding: each pass covers every resource the nation
+ // is still short of, converters first so the power pass sees their draw. It
+ // stops as soon as a pass changes nothing. A work off the transport network
+ // gets a one-tile road spur where it can, so its output can actually reach a
+ // city.
+ _layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }) {
+ const used = new Set();
+ let cursor = 0;
+ const takeSpot = (component) => {
+ if (component !== undefined && byComponent.has(component)) {
+ const list = byComponent.get(component);
+ while (list.length > 0) {
+ const coords = list.shift();
+ const k = key(coords.x, coords.y);
+ if (this.tileImprovements.has(k) || used.has(k)) continue;
+ used.add(k);
+ return coords;
+ }
+ }
+ while (cursor < spots.length) {
+ const coords = spots[cursor];
+ cursor += 1;
+ const k = key(coords.x, coords.y);
+ if (this.tileImprovements.has(k) || used.has(k)) continue;
+ used.add(k);
+ return coords;
+ }
+ return null;
+ };
+ const place = (proto, component, count) => {
+ let laid = 0;
+ for (let i = 0; i < count; i++) {
+ const coords = takeSpot(component);
+ if (!coords) break;
+ const k = key(coords.x, coords.y);
+ this.tileImprovements.set(k, proto.id);
+ this.tileImprovementOwner.set(k, civ);
+ this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000);
+ // A work beside the network gets a spur; one already on it needs none.
+ if (!this.roads.has(k) && !this.railways.has(k) && touchesNetwork(coords)) {
+ this.roads.add(k);
+ }
+ laid += 1;
+ }
+ return laid;
+ };
+ for (let pass = 0; pass < SEED_MAX_PASSES; pass++) {
+ let changed = false;
+ const deficits = this._resourceSeedDeficits(civ);
+ for (const id of ["steel", "luxury", "hightech"]) {
+ const target = deficits[id] * SEED_MATERIAL_MARGIN;
+ if (target <= 0) continue;
+ const proto = this._pickSeedProducer(rng, id);
+ const count = Math.max(1, Math.ceil(target / proto.outputPerDay));
+ if (place(proto, undefined, count) > 0) changed = true;
+ }
+ // Power is settled per connected grid, after the converters just placed
+ // have added their draw, and the plants themselves add fuel and upkeep.
+ // The GDP figure the ledger reads must reflect those converters, so the
+ // per-tile cache is dropped before it is consulted.
+ this._gdpPerCapitaCache.clear();
+ const energy = this._resourceSeedEnergy(civ, components, foodEnergy);
+ for (const entry of energy) {
+ const target = entry.deficit * SEED_ENERGY_MARGIN;
+ if (target <= 0) continue;
+ const proto = this._pickSeedProducer(rng, "energy");
+ const count = Math.max(1, Math.ceil(target / this._netEnergyOutput(proto)));
+ if (place(proto, entry.component, count) > 0) changed = true;
+ }
+ if (!changed) break;
+ const settled = this._resourceSeedDeficits(civ);
+ const energyLeft = this._resourceSeedEnergy(civ, components, foodEnergy);
+ if (settled.steel <= 1 && settled.luxury <= 1 && settled.hightech <= 1 &&
+ energyLeft.every((entry) => entry.deficit <= 1e-6)) break;
+ }
+ },
+
+ // What a nation's lands still make less than they need, in tonnes or carats a
+ // day: the people's appetite plus the works' own wear against what the
+ // resource buildings produce. Energy is settled separately, per grid.
+ _resourceSeedDeficits(civ) {
+ const population = this.getPlayerPopulation(civ);
+ const upkeep = this.getCivUpkeepResources(civ);
+ const made = this._civResourceProduction(civ);
+ return {
+ steel: steelNeedPerDay(population) + (upkeep.steel || 0) - made.steel,
+ luxury: luxuryNeedPerDay(population) - made.luxury,
+ hightech: (upkeep.hightech || 0) - made.hightech,
+ };
+ },
+
+ // The daily output of a nation's material buildings, by resource. Energy is
+ // not a stock, so it is left out and handled by the grid ledger.
+ _civResourceProduction(civ) {
+ const made = { steel: 0, luxury: 0, hightech: 0 };
+ for (const [k, id] of this.tileImprovements) {
+ if (this.tileImprovementOwner.get(k) !== civ) continue;
+ const proto = tileImprovementById(id);
+ if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
+ made[proto.resource] = (made[proto.resource] || 0) + proto.outputPerDay;
+ }
+ return made;
+ },
+
+ // Each of a nation's connected grids that is still short of power: the cities
+ // and industry it draws, plus the energy the land's food takes, against what
+ // the plants on it make. Returns one entry per short grid.
+ _resourceSeedEnergy(civ, components, foodEnergy) {
+ const ledger = this._buildEnergyLedger(components);
+ const result = [];
+ for (const [component, entry] of ledger) {
+ if (entry.civ !== civ) continue;
+ const food = foodEnergy.get(component) || 0;
+ const deficit = entry.cityEnergy + entry.converterEnergy + food - entry.supply;
+ if (deficit > 0) result.push({ component, deficit });
+ }
+ return result;
+ },
+
+ // The energy the land's food costs each connected grid, in kWh a day, ports
+ // included. Fixed for the whole seeding pass.
+ _foodEnergyByComponent(components) {
+ const result = new Map();
+ const regionMultipliers = new Map();
+ for (const k in this.tiles) {
+ const coords = parseKey(k);
+ if (!this._isLand(coords)) continue;
+ const population = this.tilePopulation.get(k) || 0;
+ if (population <= 0) continue;
+ const component = components.get(k);
+ if (component === undefined) continue;
+ const city = this.regionCityAt(coords);
+ let multiplier = 1;
+ if (city) {
+ multiplier = regionMultipliers.get(city.id);
+ if (multiplier === undefined) {
+ multiplier = this._regionFoodMultiplier(city);
+ regionMultipliers.set(city.id, multiplier);
+ }
+ }
+ const produced = tileFoodOutput(this.tiles[k]) * multiplier;
+ result.set(component, (result.get(component) || 0) + producerEnergyPerDay("food", produced));
+ }
+ return result;
+ },
+
+ // What a plant actually adds to its grid: its output less the fuel it burns
+ // and the power its own upkeep draws.
+ _netEnergyOutput(proto) {
+ return (proto.outputPerDay || 0) -
+ (proto.fuelEnergyPerDay || 0) -
+ protoUpkeep(proto, proto.buildCost || 0).energy;
+ },
+
+ // A random producer of one resource, chosen from the seeded catalogue.
+ _pickSeedProducer(rng, resource) {
+ const ids = SEED_PRODUCERS[resource];
+ return tileImprovementById(ids[rng.range(0, ids.length - 1)]);
+ },
+
+ getResourcePrice(id) {
+ const price = this.resourcePrices ? this.resourcePrices.get(id) : undefined;
+ return price !== undefined ? price : (RESOURCE_MARKET_BASE[id] || 1);
+ },
+
+ setResourcePrice(id, value) {
+ this.resourcePrices.set(id, value);
+ },
+
+ // The storage a tile has, or null when it is neither a city nor a resource
+ // building. Used both by the simulation and the delivery graph.
+ _resourceNodeAt(coords) {
+ const city = this.cityAt(coords);
+ if (city) return { kind: "city", civ: city.civ, city };
+ const k = key(coords.x, coords.y);
+ const id = this.tileImprovements.get(k);
+ if (!id) return null;
+ const proto = tileImprovementById(id);
+ if (!isResourceTileBuilding(proto)) return null;
+ return { kind: "building", civ: this.tileImprovementOwner.get(k), proto };
+ },
+
+ getCityResourceStock(city) {
+ return this._ensureResourceStock(key(city.coords.x, city.coords.y));
+ },
+
+ // What one city's people need in a day, and how that compares with its store.
+ getCityResourceInfo(city) {
+ const economy = this.getCityEconomy(city);
+ const population = economy.population;
+ const stock = this.getCityResourceStock(city);
+ const needs = {
+ food: foodNeedPerDay(population),
+ steel: steelNeedPerDay(population),
+ luxury: luxuryNeedPerDay(population),
+ };
+ const shortage = this._resourceShortages.get(city.id) || {};
+ return {
+ cityId: city.id,
+ population: Math.round(population),
+ energyPerDay: cityEnergyPerDay(economy.gdp),
+ stock: { ...stock },
+ needs,
+ foodTarget: foodMonthlyTarget(population),
+ shortage: { ...shortage },
+ };
+ },
+
+ // Every resource figure one nation's cities hold, for the summary panel: the
+ // summed stock, the summed daily need and the summed production. Energy has
+ // no store, so it travels as a daily demand and the plants' daily supply.
+ getCivResourceSummary(civ) {
+ const stock = emptyStock();
+ const need = emptyStock();
+ let energyNeed = 0;
+ for (const city of this.cities) {
+ if (city.civ !== civ) continue;
+ const store = this.getCityResourceStock(city);
+ for (const id of STORABLE_RESOURCE_IDS) stock[id] += store[id] || 0;
+ const economy = this.getCityEconomy(city);
+ const population = economy.population;
+ need.food += foodNeedPerDay(population);
+ need.steel += steelNeedPerDay(population);
+ need.luxury += luxuryNeedPerDay(population);
+ energyNeed += cityEnergyPerDay(economy.gdp);
+ }
+ const production = emptyStock();
+ let energySupply = 0;
+ for (const [k, id] of this.tileImprovements) {
+ if (this.tileImprovementOwner.get(k) !== civ) continue;
+ const proto = tileImprovementById(id);
+ if (!isResourceTileBuilding(proto)) continue;
+ if (proto.resource === "energy") energySupply += proto.outputPerDay;
+ else production[proto.resource] += proto.outputPerDay;
+ }
+ // The land's harvest is part of what the nation produces.
+ const foodMultipliers = new Map();
+ for (const coords of this._territoryByCiv.get(civ) || []) {
+ const k = key(coords.x, coords.y);
+ if ((this.tilePopulation.get(k) || 0) <= 0) continue;
+ const city = this.regionCityAt(coords);
+ let multiplier = 1;
+ if (city) {
+ multiplier = foodMultipliers.get(city.id);
+ if (multiplier === undefined) {
+ multiplier = this._regionFoodMultiplier(city);
+ foodMultipliers.set(city.id, multiplier);
+ }
+ }
+ production.food += tileFoodOutput(this.tiles[k]) * multiplier;
+ }
+ const upkeep = this.getCivUpkeepResources(civ);
+ // What the nation actually consumes in a day: the people's needs plus the
+ // materials its buildings, units, works and transport wear out. Energy is a
+ // flow on both sides, so its row combines the grid demand and upkeep.
+ const consumed = {
+ food: need.food + (upkeep.food || 0),
+ steel: need.steel + (upkeep.steel || 0),
+ luxury: need.luxury + (upkeep.luxury || 0),
+ hightech: upkeep.hightech || 0,
+ energy: energyNeed + (upkeep.energy || 0),
+ };
+ return {
+ stock,
+ need,
+ production,
+ energy: { need: energyNeed, supply: energySupply },
+ // What the nation's buildings, units, works and transport wear out daily.
+ upkeep,
+ consumed,
+ // Yesterday's cross-nation deliveries: what the nation imported and sold.
+ trade: this._civResourceTrade(civ),
+ prices: Object.fromEntries(RESOURCE_IDS.map((id) => [id, this.getResourcePrice(id)])),
+ };
+ },
+
+ // One nation's imports and exports of every resource over the last simulated
+ // day, as plain per-resource objects. Cross-nation deliveries only; the
+ // global market is not attributed to a partner.
+ _civResourceTrade(civ) {
+ const record = this.resourceTrade ? this.resourceTrade.get(civ) : null;
+ const imports = {};
+ const exports = {};
+ for (const id of RESOURCE_IDS) {
+ imports[id] = record ? record.imports[id] || 0 : 0;
+ exports[id] = record ? record.exports[id] || 0 : 0;
+ }
+ return { imports, exports };
+ },
+
+ // Records a cross-nation delivery: the buyer gained `amount` of `id` and the
+ // seller shipped it. Both nations' daily trade ledgers are updated.
+ _recordResourceTrade(importer, exporter, id, amount) {
+ if (!(amount > 0)) return;
+ if (!this.resourceTrade) this.resourceTrade = new Map();
+ const record = (civ, kind) => {
+ if (civ < 0) return;
+ let entry = this.resourceTrade.get(civ);
+ if (!entry) {
+ entry = { imports: {}, exports: {} };
+ this.resourceTrade.set(civ, entry);
+ }
+ entry[kind][id] = (entry[kind][id] || 0) + amount;
+ };
+ record(importer, "imports");
+ if (exporter !== importer) record(exporter, "exports");
+ },
+
+ // --------------------------------------------------- construction steel --
+
+ // The steel and high-tech a construction spends. Every item needs steel
+ // proportional to its money cost; an advanced building (science, air, radar,
+ // chips) also needs high-tech, more with every level.
+ constructionResourceCost(proto, level = 0, buildCost = null) {
+ return sharedConstructionResourceCost(proto, level, buildCost);
+ },
+
+ // Pays a construction's material bill. The stock of the nation's cities is
+ // drawn down first, then the global market covers the rest at its current
+ // price. Energy is a flow rather than a store, so it is bought from the
+ // market outright. Returns false only when the nation cannot afford the
+ // market bill, in which case nothing is charged and the drawn stock is put
+ // back.
+ _payConstructionResources(civ, coords, resources, category = "construction") {
+ const steel = resources.steel || 0;
+ const hightech = resources.hightech || 0;
+ const energy = resources.energy || 0;
+ if (steel <= 0 && hightech <= 0 && energy <= 0) return true;
+ const fromStock = this._drawFromNearest(civ, coords, { steel, hightech });
+ const shortfall = {
+ steel: steel - fromStock.steel,
+ hightech: hightech - fromStock.hightech,
+ };
+ const materialsShort = shortfall.steel > 0 || shortfall.hightech > 0;
+ // No working port means no foreign market: the stores must cover the bill.
+ // Energy is the exception -- a nation always powers its own works.
+ if (materialsShort && !this._civMarketAccess(civ)) {
+ this._refundToNearest(civ, coords, fromStock);
+ return false;
+ }
+ const buyEnergy = this._civMarketAccess(civ) ? energy : 0;
+ const steelCost = this._marketCost(shortfall.steel, "steel");
+ const techCost = this._marketCost(shortfall.hightech, "hightech");
+ const energyCost = this._marketCost(buyEnergy, "energy");
+ const cost = steelCost + techCost + energyCost;
+ if (!this._spendBudget(civ, cost, "construction")) {
+ this._refundToNearest(civ, coords, fromStock);
+ return false;
+ }
+ // The whole bill, paid or drawn from stores, is the construction's material
+ // cost for the month's budget report.
+ this._recordBudgetEconomic(
+ civ,
+ category,
+ steel * this.getResourcePrice("steel") +
+ hightech * this.getResourcePrice("hightech") +
+ energy * this.getResourcePrice("energy")
+ );
+ if (steelCost > 0) this._recordBudgetCategoryResource(civ, category, "steel", steelCost);
+ if (techCost > 0) this._recordBudgetCategoryResource(civ, category, "hightech", techCost);
+ if (energyCost > 0) this._recordBudgetCategoryResource(civ, category, "energy", energyCost);
+ if (cost > 0) {
+ this._recordResourceSpend(civ, "construction", cost, {
+ steel: steelCost,
+ hightech: techCost,
+ energy: energyCost,
+ });
+ }
+ const steelMarket = this.resourceMarketStats.get("steel");
+ if (steelMarket) steelMarket.demand += shortfall.steel;
+ const techMarket = this.resourceMarketStats.get("hightech");
+ if (techMarket) techMarket.demand += shortfall.hightech;
+ if (buyEnergy > 0) {
+ const energyMarket = this.resourceMarketStats.get("energy");
+ if (energyMarket) energyMarket.demand += buyEnergy;
+ }
+ return true;
+ },
+
+ _marketCost(amount, id) {
+ if (amount <= 0) return 0;
+ return amount * this.getResourcePrice(id);
+ },
+
+ // Pulls each material from the nearest of the nation's cities to `coords`,
+ // capped by what that city holds. Returns how much of each was taken.
+ _drawFromNearest(civ, coords, wanted) {
+ const taken = { steel: 0, hightech: 0 };
+ const remaining = { steel: wanted.steel || 0, hightech: wanted.hightech || 0 };
+ const cities = this.cities
+ .filter((city) => city.civ === civ)
+ .sort((a, b) =>
+ this.topology.tileDistance(a.coords, coords) -
+ this.topology.tileDistance(b.coords, coords));
+ for (const city of cities) {
+ const store = this.getCityResourceStock(city);
+ for (const id of ["steel", "hightech"]) {
+ if (remaining[id] <= 0) continue;
+ const available = store[id] || 0;
+ const take = Math.min(available, remaining[id]);
+ if (take <= 0) continue;
+ store[id] = available - take;
+ remaining[id] -= take;
+ taken[id] += take;
+ }
+ if (remaining.steel <= 0 && remaining.hightech <= 0) break;
+ }
+ return taken;
+ },
+
+ _refundToNearest(civ, coords, resources) {
+ let city = null;
+ for (const other of this.cities) {
+ if (other.civ !== civ) continue;
+ if (!city || this.topology.tileDistance(other.coords, coords) <
+ this.topology.tileDistance(city.coords, coords)) city = other;
+ }
+ if (!city) return;
+ const store = this.getCityResourceStock(city);
+ store.steel += resources.steel || 0;
+ store.hightech += resources.hightech || 0;
+ },
+
+ // ---------------------------------------------------------- upkeep --
+
+ // What one city building level wears out in a day. A level's upkeep is priced
+ // off the cost of raising that level, so it grows with the level.
+ buildingUpkeepResources(proto, level) {
+ return protoUpkeep(proto, buildingBuildCost(proto, level - 1));
+ },
+
+ unitUpkeepResources(proto) {
+ return protoUpkeep(proto, proto.cost);
+ },
+
+ // Everything a nation's buildings, units, tile works and transport network
+ // wear out in a day, split by the budget category that owns it, so the monthly
+ // report can attribute the cash and the market value. Energy is the exception
+ // units do not pay: it is fed to the grid through the city/improvement a thing
+ // stands in, so it is settled by `_buildEnergyLedger` instead.
+ getCivUpkeepBreakdown(civ) {
+ const categories = {
+ buildings: { steel: 0, energy: 0, hightech: 0 },
+ units: { steel: 0, energy: 0, hightech: 0 },
+ works: { steel: 0, energy: 0, hightech: 0 },
+ transport: { steel: 0, energy: 0, hightech: 0 },
+ };
+ const add = (category, costs) => {
+ category.steel += costs.steel || 0;
+ category.energy += costs.energy || 0;
+ category.hightech += costs.hightech || 0;
+ };
+ for (const city of this.cities) {
+ if (city.civ !== civ) continue;
+ for (const [index, level] of Object.entries(city.buildings || {})) {
+ if (level <= 0) continue;
+ const proto = this.protoBuildings[Number(index)];
+ if (proto) add(categories.buildings, this.buildingUpkeepResources(proto, level));
+ }
+ }
+ const battle = this._battleUnitIds();
+ for (const unit of this.units) {
+ if (unit.civ !== civ) continue;
+ const proto = this.unitProto(unit);
+ if (!proto) continue;
+ // A unit in battle burns through its stores faster.
+ const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
+ const multiplier = this._statusUpkeepMultiplier(statuses);
+ const costs = this.unitUpkeepResources(proto);
+ add(categories.units, {
+ steel: costs.steel * multiplier,
+ hightech: costs.hightech * multiplier,
+ });
+ }
+ for (const [k, id] of this.tileImprovements) {
+ if (this.tileImprovementOwner.get(k) !== civ) continue;
+ const proto = tileImprovementById(id);
+ if (!proto) continue;
+ add(categories.works, protoUpkeep(proto, proto.buildCost || 0));
+ if (proto.fuelEnergyPerDay) categories.works.energy += proto.fuelEnergyPerDay;
+ }
+ const counts = this._transportTiles(civ);
+ add(categories.transport, {
+ steel: counts.road * RESOURCE_RULES.transportUpkeepSteel.road +
+ counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway,
+ });
+ return categories;
+ },
+
+ // The same daily wear as one materials demand, for the summary panels and the
+ // market-value forecast.
+ getCivUpkeepResources(civ) {
+ const categories = this.getCivUpkeepBreakdown(civ);
+ const total = { steel: 0, energy: 0, hightech: 0, food: 0 };
+ for (const costs of Object.values(categories)) {
+ total.steel += costs.steel;
+ total.energy += costs.energy;
+ total.hightech += costs.hightech;
+ }
+ return total;
+ },
+
+ // An infantry unit's soldiers eat like civilians, doubled in combat and
+ // tripled while wounded and resting in one of their own cities. Used by the
+ // supply tick to decide how fast a unit's carried food runs down.
+ _unitFoodRate(unit, proto, inBattle = false) {
+ const population = proto && proto.infantry ? (proto.population || RESOURCE_RULES.unitPopulation) : 0;
+ if (population <= 0) return 0;
+ const city = this.cityAt(unit.coords);
+ const resting = !!(city && city.civ === unit.civ && unit.hp < unit.maxHp);
+ const multiplier = resting
+ ? RESOURCE_RULES.infantryRestingFoodMultiplier
+ : inBattle
+ ? RESOURCE_RULES.infantryCombatFoodMultiplier
+ : 1;
+ return foodNeedPerDay(population) * multiplier;
+ },
+
+ // Pulls a material from the nation's cities, nearest first not required, and
+ // returns what is still missing.
+ _drawFromCities(civ, id, amount) {
+ let remaining = Math.max(0, amount);
+ for (const city of this.cities) {
+ if (remaining <= 0) break;
+ if (city.civ !== civ) continue;
+ const store = this.getCityResourceStock(city);
+ const take = Math.min(store[id] || 0, remaining);
+ if (take <= 0) continue;
+ store[id] = (store[id] || 0) - take;
+ remaining -= take;
+ }
+ return remaining;
+ },
+
+ // Settles one nation's daily upkeep materials: draw the stores, buy the rest
+ // from the market with the treasury, and return the fraction of the bill the
+ // nation could actually pay. The unpaid fraction becomes unrest.
+ _settleUpkeep(civ, categories) {
+ const ids = ["buildings", "units", "works", "transport"];
+ const steelUnit = this._marketUnitCost("steel");
+ const techUnit = this._marketUnitCost("hightech");
+ let totalSteel = 0;
+ let totalTech = 0;
+ let drawnSteel = 0;
+ let drawnTech = 0;
+ const shortfalls = {};
+ // Draw each category's demand from the shared city stores. The totals do
+ // not depend on the order, only which category gets the stock first does.
+ for (const id of ids) {
+ const demand = categories[id] || {};
+ const steel = demand.steel || 0;
+ const hightech = demand.hightech || 0;
+ const steelShort = steel > 0 ? this._drawFromCities(civ, "steel", steel) : 0;
+ const techShort = hightech > 0 ? this._drawFromCities(civ, "hightech", hightech) : 0;
+ shortfalls[id] = { steel: steelShort, hightech: techShort };
+ totalSteel += steel;
+ totalTech += hightech;
+ drawnSteel += steel - steelShort;
+ drawnTech += hightech - techShort;
+ }
+ const steelShortTotal = totalSteel - drawnSteel;
+ const techShortTotal = totalTech - drawnTech;
+ // What the stores covered, against what still has to be bought.
+ const storeCost = drawnSteel * steelUnit + drawnTech * techUnit;
+ const shortfall = steelShortTotal * steelUnit + techShortTotal * techUnit;
+ const full = storeCost + shortfall;
+ // The market value of a category's wear is an economic cost whether the
+ // stores covered it or the treasury had to buy it.
+ for (const id of ids) {
+ const demand = categories[id] || {};
+ this._recordBudgetEconomic(
+ civ,
+ id,
+ (demand.steel || 0) * steelUnit + (demand.hightech || 0) * techUnit
+ );
+ }
+ if (storeCost > 0 && shortfall <= 0) return 1;
+ if (full <= 0) return 1;
+ // No port, no foreign market: only the stores count, and the rest is unmet.
+ if (!this._civMarketAccess(civ)) return storeCost / full;
+ // The government can always pay, borrowing the rest from its central bank,
+ // so the whole bill is settled and the fraction is one.
+ const fraction = 1;
+ if (shortfall > 0) {
+ this._spendBudget(civ, shortfall * fraction);
+ this._recordResourceSpend(civ, "upkeep", shortfall * fraction, {
+ steel: steelShortTotal * steelUnit * fraction,
+ hightech: techShortTotal * techUnit * fraction,
+ });
+ for (const id of ids) {
+ const short = shortfalls[id];
+ const steelCash = short.steel * steelUnit * fraction;
+ const techCash = short.hightech * techUnit * fraction;
+ const cash = steelCash + techCash;
+ if (cash > 0) this._recordBudgetCash(civ, id, -cash);
+ if (steelCash > 0) this._recordBudgetCategoryResource(civ, id, "steel", steelCash);
+ if (techCash > 0) this._recordBudgetCategoryResource(civ, id, "hightech", techCash);
+ }
+ const steelMarket = this.resourceMarketStats.get("steel");
+ if (steelMarket) steelMarket.demand += steelShortTotal * fraction;
+ const techMarket = this.resourceMarketStats.get("hightech");
+ if (techMarket) techMarket.demand += techShortTotal * fraction;
+ }
+ return (storeCost + shortfall * fraction) / full;
+ },
+
+ // ------------------------------------------------------ combat use --
+
+ // What one use of a weapon spends. Guns and aircraft are cheap parked and
+ // costly to fire: the state draws its stores and buys the rest. The payment
+ // is atomic -- if the treasury cannot cover the market part, the stores are
+ // put back and nothing is spent. Returns whether the shot could be paid for.
+ payCombatResources(civ, kind, count = 1) {
+ const consumption = RESOURCE_RULES.combatConsumption[kind];
+ if (!consumption) return true;
+ const wanted = {
+ steel: (consumption.steel || 0) * count,
+ hightech: (consumption.hightech || 0) * count,
+ };
+ if (wanted.steel <= 0 && wanted.hightech <= 0) return true;
+ const drawn = this._drawFromNearest(civ, this._anyCityCoords(civ), wanted);
+ const missing = {
+ steel: wanted.steel - drawn.steel,
+ hightech: wanted.hightech - drawn.hightech,
+ };
+ // No port, no foreign market: the stores must cover the whole shot.
+ if ((missing.steel > 0 || missing.hightech > 0) && !this._civMarketAccess(civ)) {
+ this._refundToNearest(civ, this._anyCityCoords(civ), drawn);
+ return false;
+ }
+ const cost = missing.steel * this._marketUnitCost("steel") +
+ missing.hightech * this._marketUnitCost("hightech");
+ // The government can always pay; an overdraft is a central-bank debt.
+ this._spendBudget(civ, cost, "military");
+ if (cost > 0) {
+ this._recordBudgetCategoryResource(
+ civ, "military", "steel", missing.steel * this._marketUnitCost("steel")
+ );
+ this._recordBudgetCategoryResource(
+ civ, "military", "hightech", missing.hightech * this._marketUnitCost("hightech")
+ );
+ this._recordResourceSpend(civ, "combat", cost, {
+ steel: missing.steel * this._marketUnitCost("steel"),
+ hightech: missing.hightech * this._marketUnitCost("hightech"),
+ });
+ const steelMarket = this.resourceMarketStats.get("steel");
+ if (steelMarket) steelMarket.demand += missing.steel;
+ const techMarket = this.resourceMarketStats.get("hightech");
+ if (techMarket) techMarket.demand += missing.hightech;
+ }
+ return true;
+ },
+
+ _anyCityCoords(civ) {
+ for (const city of this.cities) if (city.civ === civ) return city.coords;
+ return { x: 0, y: 0 };
+ },
+
+ // ------------------------------------------------------- market access --
+
+ // Which landmass a tile sits on. Continents never change, so they are built
+ // once and cached.
+ _continentOf(coords) {
+ if (!this._continentByTile) this._buildContinents();
+ return this._continentByTile.get(key(coords.x, coords.y));
+ },
+
+ _buildContinents() {
+ this._continentByTile = new Map();
+ let id = 0;
+ for (const k in this.tiles) {
+ if (this.tiles[k].terrainClass !== "Land") continue;
+ if (this._continentByTile.has(k)) continue;
+ const queue = [parseKey(k)];
+ this._continentByTile.set(k, id);
+ while (queue.length > 0) {
+ const coords = queue.pop();
+ for (const neighbour of this._neighbours(coords)) {
+ const nk = key(neighbour.x, neighbour.y);
+ if (this._continentByTile.has(nk)) continue;
+ const tile = this.tiles[nk];
+ if (!tile || tile.terrainClass !== "Land") continue;
+ this._continentByTile.set(nk, id);
+ queue.push(neighbour);
+ }
+ }
+ id += 1;
+ }
+ },
+
+ // Whether a city can reach another continent's markets: its own continent
+ // holds a working friendly port. No working port, no foreign resources.
+ _cityMarketAccess(city) {
+ const continent = this._continentOf(city.coords);
+ for (const port of this.cities) {
+ if (port.civ !== city.civ) continue;
+ if (this._continentOf(port.coords) !== continent) continue;
+ if (this._hasSeaSupply(port.coords, port.civ)) return true;
+ }
+ return false;
+ },
+
+ _civMarketAccess(civ) {
+ for (const city of this.cities) {
+ if (city.civ !== civ) continue;
+ if (this._cityMarketAccess(city)) return true;
+ }
+ return false;
+ },
+
+ // ------------------------------------------------------------ daily tick --
+
+ _tickResources() {
+ if (!this.resourceStock) return;
+ this._resourceVersion += 1;
+ this.resourceLinks = [];
+ this.resourceSpend = new Map();
+ this.resourceTrade = new Map();
+ this._resourceShortages = new Map();
+ const market = new Map();
+ for (const id of RESOURCE_IDS) market.set(id, { supply: 0, demand: 0, price: this.getResourcePrice(id) });
+ this.resourceMarketStats = market;
+
+ const components = this._resourceLandComponents();
+ const ledger = this._buildEnergyLedger(components);
+
+ // Food is grown by every worked tile, from the land and how fertile it is
+ // rather than from the headcount, and stored by the region's city. Ports
+ // bring in extra, so a coastal region's multiplier is looked up once per
+ // region and reused for every tile it covers. The energy it takes is
+ // charged to the tile's grid component.
+ const pendingFood = [];
+ const foodMultipliers = new Map();
+ for (const k in this.tiles) {
+ const coords = parseKey(k);
+ if (!this._isLand(coords)) continue;
+ const population = this.tilePopulation.get(k) || 0;
+ if (population <= 0) continue;
+ const city = this.regionCityAt(coords);
+ if (!city) continue;
+ let foodMultiplier = foodMultipliers.get(city.id);
+ if (foodMultiplier === undefined) {
+ foodMultiplier = this._regionFoodMultiplier(city);
+ foodMultipliers.set(city.id, foodMultiplier);
+ }
+ const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier;
+ if (produced <= 0) continue;
+ pendingFood.push({
+ cityId: city.id,
+ coords: city.coords,
+ component: components.get(k),
+ amount: produced,
+ });
+ const component = ledger.get(components.get(k));
+ if (component) component.foodEnergy += producerEnergyPerDay("food", produced);
+ }
+
+ // Settle every connected grid: what the plants make against what the cities
+ // and industry draw. The shortfall is bought from the market and capped by
+ // what the nation can pay.
+ for (const component of ledger.values()) {
+ if (component.civ < 0) continue;
+ const demand = component.cityEnergy + component.converterEnergy + component.foodEnergy;
+ const deficit = Math.max(0, demand - component.supply);
+ component.demand = demand;
+ component.available = component.supply + this._buyGridEnergy(component.civ, deficit);
+ const industrial = component.converterEnergy + component.foodEnergy;
+ component.industrialScale = industrial > 0
+ ? Math.max(0, Math.min(1, (component.available - component.cityEnergy) / industrial))
+ : 1;
+ const cityServed = Math.min(
+ component.cityEnergy,
+ Math.max(0, component.available - industrial * component.industrialScale)
+ );
+ component.cityShortage = component.cityEnergy > 0
+ ? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
+ : 0;
+ market.get("energy").supply += component.supply;
+ market.get("energy").demand += demand;
+ }
+
+ // Food lands in the region's store, scaled by the power the grid could give.
+ for (const entry of pendingFood) {
+ const component = ledger.get(entry.component);
+ const scale = component ? component.industrialScale : 1;
+ const produced = entry.amount * scale;
+ if (produced <= 0) continue;
+ this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced;
+ market.get("food").supply += produced;
+ }
+
+ // Converters turn the power they were allocated into their material, held
+ // at the building's own tile.
+ for (const [k, id] of this.tileImprovements) {
+ const proto = tileImprovementById(id);
+ if (!isResourceTileBuilding(proto) || proto.resource === "energy") continue;
+ const component = ledger.get(components.get(k));
+ const scale = component ? component.industrialScale : 1;
+ const produced = proto.outputPerDay * scale;
+ if (produced <= 0) continue;
+ this._ensureResourceStock(k)[proto.resource] += produced;
+ market.get(proto.resource).supply += produced;
+ }
+
+ // The people eat, wear and spend. Shortfalls are matched against nearby
+ // stores, then the market.
+ this._consumeCityResources(components, market);
+
+ // Every nation's buildings, units and works then wear out: the state draws
+ // its stores and buys the rest, and an unpaid bill becomes unrest.
+ for (let civ = 0; civ < this.civilisations.length; civ++) {
+ const categories = this.getCivUpkeepBreakdown(civ);
+ const paid = this._settleUpkeep(civ, categories);
+ this._civilianUpkeepShortage(civ, 1 - paid);
+ }
+
+ // Once a day the world's imbalance sets the next price.
+ for (const id of RESOURCE_IDS) {
+ const stats = market.get(id);
+ const price = nextMarketPrice(
+ RESOURCE_MARKET_BASE[id],
+ this.getResourcePrice(id),
+ stats.supply,
+ stats.demand
+ );
+ this.setResourcePrice(id, price);
+ stats.price = price;
+ }
+
+ this._applyResourceShortages(ledger);
+ this._pruneResourceStock();
+ },
+
+ // Every land tile assigned to the connected component of owned land it sits
+ // on. Two cities of a nation share a grid only when their land is contiguous.
+ _resourceLandComponents() {
+ const component = new Map();
+ let next = 0;
+ for (const [startKey, owner] of this.territory) {
+ if (component.has(startKey)) continue;
+ const queue = [parseKey(startKey)];
+ component.set(startKey, next);
+ while (queue.length > 0) {
+ const coords = queue.pop();
+ for (const neighbour of this._neighbours(coords)) {
+ const nk = key(neighbour.x, neighbour.y);
+ if (component.has(nk)) continue;
+ if (this.territory.get(nk) !== owner) continue;
+ component.set(nk, next);
+ queue.push(neighbour);
+ }
+ }
+ next += 1;
+ }
+ return component;
+ },
+
+ // A per-component energy ledger, keyed by the component id from
+ // `_resourceLandComponents`. Each entry carries the owner and the day's draw and
+ // supply, filled in as the world is walked.
+ _buildEnergyLedger(components) {
+ const ledger = new Map();
+ for (const [k, id] of components) {
+ if (ledger.has(id)) continue;
+ ledger.set(id, {
+ civ: this.territory.get(k),
+ supply: 0,
+ cityEnergy: 0,
+ converterEnergy: 0,
+ foodEnergy: 0,
+ demand: 0,
+ available: 0,
+ industrialScale: 1,
+ cityShortage: 0,
+ });
+ }
+ for (const city of this.cities) {
+ const component = ledger.get(components.get(key(city.coords.x, city.coords.y)));
+ if (!component) continue;
+ component.cityEnergy += cityEnergyPerDay(this.getCityEconomy(city).gdp);
+ // The city's buildings run on the grid too, so their operating power is
+ // charged to the same component.
+ for (const [index, level] of Object.entries(city.buildings || {})) {
+ if (level <= 0) continue;
+ const proto = this.protoBuildings[Number(index)];
+ if (proto) component.cityEnergy += this.buildingUpkeepResources(proto, level).energy;
+ }
+ }
+ for (const [k, id] of this.tileImprovements) {
+ const proto = tileImprovementById(id);
+ if (!proto) continue;
+ const component = ledger.get(components.get(k));
+ if (!component) continue;
+ if (isResourceTileBuilding(proto) && proto.resource === "energy") {
+ component.supply += proto.outputPerDay;
+ } else if (isResourceTileBuilding(proto)) {
+ component.converterEnergy += proto.energyPerDay || 0;
+ }
+ component.cityEnergy += protoUpkeep(proto, proto.buildCost || 0).energy;
+ if (proto.fuelEnergyPerDay) component.cityEnergy += proto.fuelEnergyPerDay;
+ }
+ return ledger;
+ },
+
+ // Buys the grid's electric shortfall on the market, capped by what the nation
+ // can pay. Returns the kWh actually bought.
+ _buyGridEnergy(civ, deficit) {
+ if (deficit <= 0) return 0;
+ // Without a working port there is no foreign market to buy power from.
+ if (!this._civMarketAccess(civ)) return 0;
+ const price = this.getResourcePrice("energy");
+ this._spendBudget(civ, deficit * price, "energy");
+ this._recordBudgetCategoryResource(civ, "energy", "energy", deficit * price);
+ this._recordResourceSpend(civ, "energy", deficit * price, "energy");
+ return deficit;
+ },
+
+ _consumeCityResources(components, market) {
+ const 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) {
+ const population = this.getCityEconomy(city).population;
+ const needs = {
+ food: foodNeedPerDay(population),
+ steel: steelNeedPerDay(population),
+ luxury: luxuryNeedPerDay(population),
+ };
+ const store = this.getCityResourceStock(city);
+ const shortage = {};
+ const foodTarget = foodMonthlyTarget(population);
+ // An encircled city is cut off from neighbours and the foreign market:
+ // it lives off its own reserves until the ring is broken. A portless city
+ // can still trade over land on its own continent, but not across water.
+ const cutOff = this._isEncircled(city.coords, city.civ);
+ const marketAccess = !cutOff && this._cityMarketAccess(city);
+ for (const id of ["food", "steel", "luxury"]) {
+ const need = needs[id];
+ market.get(id).demand += need;
+ const own = Math.min(store[id] || 0, need);
+ store[id] = (store[id] || 0) - own;
+ const dailyShortfall = need - own;
+ // Food is stocked ahead: the city tops its store up to 130% of the
+ // coming month's needs, not just today's. Steel and luxury are bought
+ // as they are eaten.
+ const wanted = id === "food"
+ ? Math.max(dailyShortfall, foodTarget - store[id])
+ : dailyShortfall;
+ let bought = 0;
+ if (wanted > 0 && !cutOff) {
+ bought = this._procureFromNeighbours(
+ city, id, wanted, store, nodes, this._marketUnitCost(id), marketAccess
+ );
+ if (bought < wanted && marketAccess) {
+ bought += this._buyMarketMaterial(city, id, wanted - bought, store);
+ }
+ }
+ // What was bought against today's need is eaten today; only the food
+ // buffer left over stays in the store.
+ const consumed = Math.min(bought, dailyShortfall);
+ if (consumed > 0) store[id] -= consumed;
+ if (dailyShortfall > bought + 0.0001 && need > 0) {
+ shortage[id] = (dailyShortfall - bought) / need;
+ }
+ }
+ if (Object.keys(shortage).length > 0) this._resourceShortages.set(city.id, shortage);
+ }
+ },
+
+ // A city buys a material from the cheapest source it can reach: a nearby
+ // store whose delivered price beats the market, or the market itself. The
+ // city pays from its own private cash, and only the region that owns the
+ // surplus is paid; a foreign seller insists on its national currency, which
+ // the buyer acquires from that nation's central bank. Returns the amount
+ // bought, and records each successful leg on the delivery graph. `marketUnit`
+ // is the market's delivered price per unit; suppliers at or above it are
+ // ignored. A surplus several buyers chase is bid up: each earlier claim raises
+ // the price the next buyer pays.
+ _procureFromNeighbours(city, id, amount, cityStore, nodes, marketUnit = Infinity, allowSea = true) {
+ let remaining = amount;
+ const price = this.getResourcePrice(id);
+ const energyPrice = this.getResourcePrice("energy");
+ if (!this._nodeClaims) this._nodeClaims = new Map();
+ const buyerCode = this.currencyOf(city.civ).code;
+ const quotes = [];
+ for (const supplier of this._nearbySuppliers(city, id, nodes, allowSea)) {
+ const surplus = this._nodeSurplus(supplier, id);
+ if (surplus <= 0) continue;
+ const claimed = this._nodeClaims.get(key(supplier.coords.x, supplier.coords.y)) || 0;
+ const available = Math.max(0, surplus - claimed);
+ if (available <= 0) continue;
+ const foreign = supplier.civ >= 0 && supplier.civ !== city.civ;
+ // A contested surplus is bid up, but never past the cap.
+ const premium = Math.min(
+ MONEY.maxBidMultiplier,
+ 1 + MONEY.bidPremium * (claimed / Math.max(1e-9, surplus))
+ );
+ // Every seller is paid for the goods in its own currency -- a neighbour
+ // in the shared national currency, a foreigner after a trip to its central
+ // bank. A contested surplus carries the bid premium.
+ const goods = price * premium;
+ const freight = (supplier.routeEnergyPerTonne || 0) * energyPrice;
+ const unitCost = goods + freight;
+ if (unitCost >= marketUnit) continue;
+ quotes.push({ supplier, available, foreign, unitCost, goods, freight });
+ }
+ quotes.sort((a, b) => a.unitCost - b.unitCost);
+ for (const quote of quotes) {
+ if (remaining <= 0) break;
+ const { supplier, foreign, goods, freight } = quote;
+ const bought = Math.min(quote.available, remaining);
+ if (!(bought > 0)) continue;
+ const goodsValue = goods * bought;
+ const freightValue = freight * bought;
+ const vBuyer = this.currencyValue(city.civ);
+ // The region pays from its cash and borrows the rest from its central
+ // bank, so a purchase is never refused for want of money.
+ if (foreign) {
+ this._chargeRegionCash(city, buyerCode, freightValue / vBuyer);
+ this._payWorld(city.civ, freightValue / vBuyer, buyerCode);
+ const acquired = this._regionAcquireCurrency(city, supplier.civ, goodsValue);
+ if (!acquired) continue;
+ this._paySupplier(supplier, acquired.code, acquired.amount);
+ this._recordResourceTrade(city.civ, supplier.civ, id, bought);
+ this._recordCurrencyDemand(supplier.civ, goodsValue);
+ this._recordCurrencyDemand(city.civ, -goodsValue);
+ } else {
+ this._chargeRegionCash(city, buyerCode, (goodsValue + freightValue) / vBuyer);
+ this._paySupplier(supplier, buyerCode, goodsValue / vBuyer);
+ this._payWorld(city.civ, freightValue / vBuyer, buyerCode);
+ }
+ this._nodeClaims.set(
+ key(supplier.coords.x, supplier.coords.y),
+ (this._nodeClaims.get(key(supplier.coords.x, supplier.coords.y)) || 0) + bought
+ );
+ supplier.stock[id] -= bought;
+ cityStore[id] += bought;
+ remaining -= bought;
+ this.resourceLinks.push({
+ from: [supplier.coords.x, supplier.coords.y],
+ to: [city.coords.x, city.coords.y],
+ resource: id,
+ amount: bought,
+ });
+ }
+ return amount - remaining;
+ },
+
+ // Pays the region that owns a surplus, into its private-sector cash.
+ _paySupplier(node, code, amount) {
+ if (!(amount > 0)) return;
+ const city = node.city || this._regionCityNear(node.civ, node.coords);
+ if (city) this._creditRegionCash(city, code, amount);
+ },
+
+ // The market's delivered price per unit: the good plus the energy a nominal
+ // two-tile road delivery burns.
+ _marketUnitCost(id) {
+ const energyPerUnit = deliveryEnergyForResource(id, 1, 2, "road", 1);
+ return this.getResourcePrice(id) + energyPerUnit * this.getResourcePrice("energy");
+ },
+
+ // The market always has the goods; a city pays the price and a nominal
+ // two-tile delivery from its own private cash, in its national currency.
+ // Returns how much it could afford.
+ _buyMarketMaterial(city, id, amount, cityStore) {
+ if (amount <= 0) return 0;
+ const civ = city.civ;
+ // The foreign market is only reachable through a working port.
+ if (!this._civMarketAccess(civ)) return 0;
+ const code = this.currencyOf(civ).code;
+ const perUnit = this._marketUnitCost(id) / this.currencyValue(civ);
+ if (perUnit <= 0) return 0;
+ if (!this.regionCash.get(city.id)) return 0;
+ // The region pays from its cash and borrows the rest from its central bank,
+ // so the whole order goes through. The money is paid to the world market's
+ // producers, so it does not drain out of the world.
+ const payment = amount * perUnit;
+ this._chargeRegionCash(city, code, payment);
+ this._payWorld(civ, payment, code);
+ cityStore[id] += amount;
+ return amount;
+ },
+
+ _resourceNodes() {
+ const nodes = [];
+ for (const [k, stock] of this.resourceStock) {
+ const coords = parseKey(k);
+ const city = this.cityAt(coords);
+ if (city) {
+ nodes.push({ coords, civ: city.civ, city, stock, kind: "city" });
+ continue;
+ }
+ const id = this.tileImprovements.get(k);
+ const proto = id ? tileImprovementById(id) : null;
+ if (isResourceTileBuilding(proto)) {
+ nodes.push({
+ coords,
+ civ: this.tileImprovementOwner.get(k),
+ stock,
+ kind: "building",
+ proto,
+ });
+ }
+ }
+ return nodes;
+ },
+
+ // The storage nodes within reach of a city that hold a surplus of `id`, in
+ // order of transport energy. A breadth-first search over land and sea, capped
+ // at RESOURCE_TRADE_RADIUS tiles. `routeEnergyPerTonne` is the accumulated
+ // kWh to move one tonne from the city to that node.
+ _nearbySuppliers(city, id, nodes, allowSea = true) {
+ 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 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).
+ const queue = [{ coords: city.coords, tiles: 0, energy: 0 }];
+ while (queue.length > 0) {
+ const current = queue.shift();
+ const here = byKey.get(key(current.coords.x, current.coords.y));
+ if (here && key(here.coords.x, here.coords.y) !== origin) {
+ const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ);
+ if (!hostile && this._nodeSurplus(here, id) > 0) {
+ results.push({
+ ...here,
+ tiles: current.tiles,
+ routeEnergyPerTonne: current.energy,
+ });
+ }
+ }
+ 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;
+ const tile = this.tiles[nk];
+ if (!tile || !(tile.terrainClass === "Land" || tile.terrainClass === "Sea" ||
+ tile.terrainClass === "Ice")) continue;
+ const sea = tile.terrainClass === "Sea";
+ let mode;
+ if (sea) {
+ // A shipping lane needs a working port to load and unload.
+ if (!allowSea) continue;
+ mode = "ship";
+ } else {
+ // Ground legs must run on a road or railway; there is no off-road
+ // trucking in the trade network.
+ if (this.railways.has(nk)) mode = "train";
+ else if (this.roads.has(nk)) mode = "road";
+ else continue;
+ // Goods never cross the land of a nation you are at war with.
+ const owner = this.civAt(neighbour);
+ if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
+ }
+ const stepEnergy = deliveryEnergyForResource(id, 1, 1, mode, tile.movementCostMultiplier || 1);
+ visited.add(nk);
+ queue.push({
+ coords: neighbour,
+ tiles: current.tiles + 1,
+ energy: current.energy + stepEnergy,
+ });
+ }
+ }
+ results.sort((a, b) => a.routeEnergyPerTonne - b.routeEnergyPerTonne);
+ return results;
+ },
+
+ _nodeSurplus(node, id) {
+ if (id === "hightech") return Math.max(0, node.stock.hightech || 0);
+ const population = node.city ? this.getCityEconomy(node.city).population : 0;
+ const perDay = id === "food"
+ ? foodNeedPerDay(population)
+ : id === "steel"
+ ? steelNeedPerDay(population)
+ : id === "luxury"
+ ? luxuryNeedPerDay(population)
+ : 0;
+ return Math.max(0, (node.stock[id] || 0) - perDay * SUPPLIER_RESERVE_DAYS);
+ },
+
+ // A shortage of food, steel or luxury costs popularity; a grid starved of
+ // power costs popularity too. Each city is charged once per day.
+ _applyResourceShortages(ledger) {
+ for (const [cityId, shortage] of this._resourceShortages) {
+ const city = this._cityById.get(cityId);
+ if (!city) continue;
+ let penalty = 0;
+ for (const id of ["food", "steel", "luxury"]) {
+ penalty += (shortage[id] || 0) * RESOURCE_RULES.shortagePopularityPenalty;
+ }
+ if (penalty > 0) this._shiftOwnPopularity(city.civ, -penalty);
+ }
+ const civShortage = new Map();
+ for (const component of ledger.values()) {
+ if (component.civ < 0 || component.cityShortage <= 0) continue;
+ civShortage.set(
+ component.civ,
+ Math.max(civShortage.get(component.civ) || 0, component.cityShortage)
+ );
+ }
+ for (const [civ, shortage] of civShortage) {
+ this._shiftOwnPopularity(civ, -shortage * RESOURCE_RULES.shortagePopularityPenalty);
+ }
+ },
+
+ // An unpaid materials bill for the nation's upkeep means things are not being
+ // maintained; the people notice.
+ _civilianUpkeepShortage(civ, fraction) {
+ if (!(fraction > 0)) return;
+ this._shiftOwnPopularity(civ, -fraction * RESOURCE_RULES.shortagePopularityPenalty);
+ },
+
+ // ---------------------------------------------------------- repairs --
+
+ // Damage to a tile's structures leaves a steel debt; until it is paid down
+ // the scar does not heal and the tile's GDP recovery waits.
+ _recordRepairDebt(coords, damage) {
+ if (!(damage > 0)) return;
+ const k = key(coords.x, coords.y);
+ const debt = (this.tileRepairDebt.get(k) || 0) + damage * RESOURCE_RULES.repair.steelPerDamage;
+ this.tileRepairDebt.set(k, debt);
+ },
+
+ // A building level knocked down by bombardment waits to be rebuilt once its
+ // materials are paid for.
+ _queueCityRepair(city, index, level) {
+ const queue = this.cityRepairQueue.get(city.id) || [];
+ queue.push({ index, targetLevel: level });
+ this.cityRepairQueue.set(city.id, queue);
+ },
+
+ _tickRepairs() {
+ // Rebuild one lost city level per city per day, if the materials are there.
+ for (const [cityId, queue] of Array.from(this.cityRepairQueue)) {
+ const city = this._cityById.get(cityId);
+ if (!city || queue.length === 0) {
+ this.cityRepairQueue.delete(cityId);
+ continue;
+ }
+ const job = queue[0];
+ const proto = this.protoBuildings[job.index];
+ if (!proto) {
+ queue.shift();
+ continue;
+ }
+ const level = Math.max(1, job.targetLevel);
+ const buildCost = buildingBuildCost(proto, level - 1);
+ const cost = this.constructionResourceCost(proto, level - 1, buildCost);
+ if (!this._payConstructionResources(city.civ, city.coords, cost)) continue;
+ city.buildings[job.index] = level;
+ this._gdpPerCapitaCache.clear();
+ this._touchModifiers();
+ queue.shift();
+ if (queue.length === 0) this.cityRepairQueue.delete(cityId);
+ }
+
+ // Pay down battle damage, then let the tile work heal.
+ for (const [k, debt] of Array.from(this.tileRepairDebt)) {
+ const coords = parseKey(k);
+ const civ = this.civAt(coords);
+ if (civ < 0) {
+ this.tileRepairDebt.delete(k);
+ continue;
+ }
+ const paid = this._payTileDebt(civ, coords, debt);
+ const remaining = debt - paid;
+ if (remaining <= 0.0001) this.tileRepairDebt.delete(k);
+ else this.tileRepairDebt.set(k, remaining);
+ }
+ for (const k of Array.from(this.tileImprovements.keys())) {
+ if (this.tileRepairDebt.has(k)) continue;
+ this._repairTileWork(parseKey(k));
+ }
+ },
+
+ // Spends what steel it can on a tile's repair: the owner's nearest city
+ // first, then the market. Returns the amount actually paid.
+ _payTileDebt(civ, coords, amount) {
+ if (!(amount > 0)) return 0;
+ const fromStock = this._drawFromNearest(civ, coords, { steel: amount, hightech: 0 });
+ const shortfall = amount - fromStock.steel;
+ let paid = fromStock.steel;
+ if (shortfall > 0 && !this._civMarketAccess(civ)) return paid;
+ const cost = shortfall * this._marketUnitCost("steel");
+ if (cost > 0) {
+ // The government can always pay, borrowing if the treasury is dry.
+ const fraction = 1;
+ this._spendBudget(civ, cost * fraction, "works");
+ paid += shortfall * fraction;
+ this._recordBudgetCategoryResource(civ, "works", "steel", cost * fraction);
+ this._recordResourceSpend(civ, "upkeep", cost * fraction, "steel");
+ }
+ return paid;
+ },
+
+ // Gradually restores a damaged tile work's hit points, at a steel cost.
+ _repairTileWork(coords) {
+ const k = key(coords.x, coords.y);
+ const id = this.tileImprovements.get(k);
+ if (!id) return;
+ const max = TILE_IMPROVEMENT_HP[id] || 1000;
+ const hp = this.tileImprovementHp.get(k) || 0;
+ if (hp >= max) return;
+ const civ = this.tileImprovementOwner.get(k);
+ if (civ < 0) return;
+ const wanted = Math.min(RESOURCE_RULES.repair.tileHpPerDay, max - hp);
+ const cost = wanted * RESOURCE_RULES.repair.steelPerTileHp;
+ const paid = this._payTileDebt(civ, coords, cost);
+ const gained = Math.min(wanted, paid / RESOURCE_RULES.repair.steelPerTileHp);
+ if (gained > 0) this.tileImprovementHp.set(k, hp + gained);
+ },
+
+ // ------------------------------------------------------- unit supply --
+
+ // Land units only: aircraft have endurance and ships their own range.
+ _isLandUnitProto(proto) {
+ if (!proto || proto.air || proto.naval) return false;
+ return (proto.traversableTerrains || []).includes("Land");
+ },
+
+ _nearestAlliedCity(civ, coords) {
+ let best = null;
+ let bestDistance = Infinity;
+ for (const city of this.cities) {
+ if (city.civ !== civ) continue;
+ const distance = this.topology.tileDistance(city.coords, coords);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = city;
+ }
+ }
+ return best;
+ },
+
+ // The allied city a unit may draw food from, following the transport network:
+ // a unit at home or on a working port is fed at once; otherwise the nearest
+ // allied city reachable along roads, railways and shipping lanes (a port
+ // needed for the sea) supplies it. An encircled unit is cut off entirely.
+ _unitSupplyCity(unit) {
+ const civ = unit.civ;
+ if (this._isEncircled(unit.coords, civ)) return null;
+ 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 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;
+ const tile = this.tiles[nk];
+ if (!tile) continue;
+ const sea = tile.terrainClass === "Sea";
+ if (sea) {
+ // A shipping lane needs a working port at the nation's end.
+ if (!this._civMarketAccess(civ)) continue;
+ } else if (!(this.railways.has(nk) || this.roads.has(nk))) {
+ continue;
+ }
+ const city = this.cityAt(neighbour);
+ if (city && city.civ === civ) return city;
+ visited.add(nk);
+ queue.push(neighbour);
+ }
+ }
+ return null;
+ },
+
+ // Feeds a unit for a day from its supply city: the city's stores first, then
+ // the market on the treasury. Atomic: if neither can cover the ration, the
+ // city is not charged and the unit goes unfed.
+ _provisionUnit(unit, city, tonnes) {
+ if (!(tonnes > 0)) return true;
+ const store = this.getCityResourceStock(city);
+ const take = Math.min(store.food || 0, tonnes);
+ const remaining = tonnes - take;
+ if (remaining > 0.0001) {
+ // Feeding the unit from off-continent needs a working port.
+ if (!this._civMarketAccess(unit.civ)) return false;
+ const unitCost = this._marketUnitCost("food");
+ const cost = remaining * unitCost;
+ // The government can always pay, borrowing if the treasury is dry.
+ this._spendBudget(unit.civ, cost, "units");
+ this._recordBudgetCategoryResource(unit.civ, "units", "food", cost);
+ this._recordResourceSpend(unit.civ, "upkeep", cost, "food");
+ const market = this.resourceMarketStats.get("food");
+ if (market) market.demand += remaining;
+ }
+ store.food = (store.food || 0) - take;
+ return true;
+ },
+
+ // Every land unit eats a day from its carried stores. At home, or in foreign
+ // territory with a clear route to an allied city, it is fed and topped back
+ // up to a full pack; cut off, the days run down and then the soldiers start
+ // dying.
+ _tickUnitSupply() {
+ if (!this.resourceStock) return;
+ const battle = this._battleUnitIds();
+ const destroyed = [];
+ for (const unit of this.units) {
+ const proto = this.unitProto(unit);
+ if (!proto || !this._isLandUnitProto(proto)) continue;
+ const soldiers = proto.population || RESOURCE_RULES.unitPopulation;
+ const ration = foodNeedPerDay(soldiers);
+ const here = this.cityAt(unit.coords);
+ const resting = !!(here && here.civ === unit.civ && unit.hp < unit.maxHp);
+ const multiplier = resting
+ ? RESOURCE_RULES.infantryRestingFoodMultiplier
+ : battle.has(unit.id)
+ ? RESOURCE_RULES.infantryCombatFoodMultiplier
+ : 1;
+ const city = this._unitSupplyCity(unit);
+ const fed = city ? this._provisionUnit(unit, city, ration * multiplier) : false;
+ if (fed) {
+ unit.foodDays = RESOURCE_RULES.unitSupplyDays;
+ } else {
+ unit.foodDays -= multiplier;
+ }
+ if (unit.foodDays <= 0) {
+ unit.hp -= unit.maxHp * RESOURCE_RULES.starvationHpFractionPerDay;
+ if (unit.hp <= 0) {
+ unit.hp = 0;
+ destroyed.push(unit);
+ }
+ }
+ }
+ if (destroyed.length > 0) {
+ this._recordMilitaryCasualties(destroyed);
+ for (const unit of destroyed) this._destroyUnit(unit);
+ this._visibilityDirty = true;
+ this._emitChanged();
+ }
+ },
+
+ _pruneResourceStock() {
+ for (const k of Array.from(this.resourceStock.keys())) {
+ const coords = parseKey(k);
+ if (!this._resourceNodeAt(coords)) this.resourceStock.delete(k);
+ }
+ },
+
+ // ------------------------------------------------------- research link --
+
+ // Science buildings spend a unit of high-tech for every research point. The
+ // nation's cities cover the bill up to what they hold; the rest of the
+ // points are simply not produced. Returns the effective points.
+ _spendHighTechForResearch(civ, points) {
+ if (points <= 0) return 0;
+ let needed = points * RESOURCE_RULES.highTechPerResearchPoint;
+ if (needed <= 0) return points;
+ let available = 0;
+ const cities = this.cities.filter((city) => city.civ === civ);
+ for (const city of cities) available += this.getCityResourceStock(city).hightech || 0;
+ if (available <= 0) return 0;
+ const ratio = Math.min(1, available / needed);
+ let toSpend = needed * ratio;
+ for (const city of cities) {
+ if (toSpend <= 0) break;
+ const store = this.getCityResourceStock(city);
+ const take = Math.min(store.hightech || 0, toSpend);
+ store.hightech = (store.hightech || 0) - take;
+ toSpend -= take;
+ }
+ return points * ratio;
+ },
+};
diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js
index a3c03de..f8719c5 100644
--- a/shared/game_state/serialization.js
+++ b/shared/game_state/serialization.js
@@ -4,6 +4,7 @@
import { parseKey } from "../hex.js";
import { OPINION } from "../data/politics.js";
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
+import { RESOURCE_IDS } from "../data/resources.js";
import { serializeSet } from "./helpers.js";
export const serializationMethods = {
@@ -44,16 +45,19 @@ export const serializationMethods = {
trenches: this._serializeTrenches(),
tileImprovements: this._serializeTileImprovements(),
// Each land tile's region: [x, y, cityId], so the browser attributes a
- // tile's GDP modifiers to the city that develops it.
+ // tile's production modifiers to the city that develops it.
regions: this._serializeRegions(),
population: this._serializePopulation(),
- // Every land tile's opening GDP per capita, the constant natural growth
- // is measured from. Static, so it travels once and is then delta-dropped.
- gdpBaseline: this._serializeGdpBaseline(),
+ // Every land tile's opening production per head, the constant natural
+ // growth is measured from. Static, so it travels once and is then dropped.
+ productionBaseline: this._serializeProductionBaseline(),
+ // The production per head each region starts from, before the tile
+ // modifiers. The browser mirrors the server's tile breakdown from it.
+ regionProductionBase: this._serializeRegionProductionBase(),
// Sparse per-tile marks of war, so the browser can explain a tile's
- // GDP per capita without shipping the whole breakdown for every tile.
- tileGdpPenalties: this._serializeTileValues(this.tileGdpPenalty),
- tileBattleGdpDeficits: this._serializeTileValues(this.tileBattleGdpDeficit),
+ // production per head without shipping the whole breakdown for every tile.
+ tileProductionPenalties: this._serializeTileValues(this.tileProductionPenalty),
+ tileBattleProductionDeficits: this._serializeTileValues(this.tileBattleProductionDeficit),
civStats: this._serializeCivStats(),
cityStats: cityStats,
budgets: this._serializeCivValues(this.budgets),
@@ -87,6 +91,11 @@ export const serializationMethods = {
conflicts: this._serializeConflicts(),
news: this._serializeNews(),
battles: this._serializeBattles(),
+ // The global resource market: today's price and the supply and demand
+ // that moved it, for the resource panel.
+ resourceMarket: this._serializeResourceMarket(),
+ // Every nation's imports and exports of each resource over the last day.
+ resourceTrade: this._serializeResourceTrade(),
// Each nation's indicators a month ago, for the summary change arrows;
// empty during January 2000, which has no past month.
monthly: this.monthlyStats || [],
@@ -111,7 +120,8 @@ export const serializationMethods = {
warfare: this._tileImprovementVersion,
cityStats: hashCityStats(cityStats),
visible: this._visibleVersion,
- gdpBaseline: this._gdpBaselineVersion,
+ productionBaseline: this._productionBaselineVersion,
+ resourceGraph: this._resourceVersion,
},
};
},
@@ -126,6 +136,9 @@ export const serializationMethods = {
// an empty queue, so neither the map bar nor the city panel can reveal
// whether a foreign city is busy.
result.training = trainingForViewer(shared.training, shared.cities, viewerCiv);
+ // The delivery graph is the viewer's own network: its storage nodes and the
+ // legs that carried goods into its cities. Only the economic map reveals it.
+ result.resourceGraph = this._serializeResourceGraph(viewerCiv);
return result;
},
@@ -136,6 +149,7 @@ export const serializationMethods = {
upkeep: 0, researchRate: 0, culture: 0, government: 0,
approval: 0.5, popularity: 0, cultureImpact: 0, ethnicMakeup: [],
constructionSpeed: 0, railSpeed: 0,
+ resources: null,
breakdown: null,
};
}
@@ -146,8 +160,13 @@ export const serializationMethods = {
// Repeatable research the browser mirrors: construction and rail speed.
constructionSpeed: modifiers[EFFECT_CONSTRUCTION_SPEED] || 0,
railSpeed: modifiers[EFFECT_RAIL_SPEED] || 0,
+ // The nation's stores, daily needs and production, plus today's prices.
+ resources: this.getCivResourceSummary(viewerCiv),
budget: this.getBudget(viewerCiv),
- upkeep: this.getPlayerUpkeep(viewerCiv),
+ // The upkeep figure the HUD nets against income: propaganda money plus
+ // the market value of the materials buildings, units and works wear out.
+ upkeep: this.getPlayerUpkeep(viewerCiv) +
+ this._upkeepCost(this.getCivUpkeepResources(viewerCiv)),
// The research this nation creates each hour, all of which flows into the
// focused technology. There is no stock of points.
researchRate: modifiers[EFFECT_RESEARCH] || 0,
@@ -158,12 +177,42 @@ export const serializationMethods = {
cultureImpact: this.getCulturalImpact(viewerCiv),
ethnicMakeup: this.getCivEthnicMakeup(viewerCiv),
breakdown: this.budgetBreakdown(viewerCiv),
+ // The month's budget, plus the last two years of monthly digests, so the
+ // panel can report what has actually been spent rather than a rate.
+ budgetMonths: this._serializeBudgetMonths(viewerCiv),
+ // Money: the nation's currency, everyone else's, the exchange rates the
+ // viewer sees, the central bank's rate and reserves, and the aggregate
+ // private-sector cash its regions hold.
+ currency: this.currencyOf(viewerCiv),
+ currencies: this._serializeCurrencies(),
+ exchangeRates: this._serializeExchangeRates(viewerCiv),
+ centralBank: this._serializeCentralBank(viewerCiv),
+ privateCash: this.getPrivateSectorCash(viewerCiv),
+ governmentDebt: this.getGovernmentDebt(viewerCiv),
// How far the headline figures have grown, against January 2000 in the
// first year and year-on-year afterwards.
growth: this._growthRates(viewerCiv),
};
},
+ // The monthly budget reports, compacted for the wire: category ids replace
+ // the labels the client already knows from the data catalogue.
+ _serializeBudgetMonths(civ) {
+ return this.budgetMonthReports(civ).map((report) => ({
+ monthIndex: report.monthIndex,
+ isCurrent: report.isCurrent,
+ income: report.income,
+ sales: report.sales,
+ totalCash: report.totalCash,
+ totalEconomic: report.totalEconomic,
+ net: report.net,
+ expenses: report.expenses.map(
+ (entry) => [entry.id, entry.cash, entry.economic, entry.buys]
+ ),
+ resourceTrade: report.resourceTrade.map((entry) => [entry.id, entry.bought, entry.sold]),
+ }));
+ },
+
_serializeCities() {
return this.cities.map((city) => ({
id: city.id,
@@ -207,6 +256,8 @@ export const serializationMethods = {
airHours: unit.airHours,
airborne: !!unit.airborne,
homeCityId: unit.homeCityId,
+ // A land unit's carried food, in days. Zero means it is starving.
+ foodDays: unit.foodDays,
// A standing bombardment target (ground battery) or bomb-run target
// (aircraft), as [x, y], and the hour an aircraft may strike again.
strikeTarget: unit.strikeTarget
@@ -298,22 +349,32 @@ export const serializationMethods = {
return result;
},
- // A sparse [x, y, value] list of every land tile's opening GDP per capita. It
- // never changes, so it is memoised and the delta wire drops it after the first
- // snapshot a peer receives.
- _serializeGdpBaseline() {
- if (this._serializedGdpBaselineCache) return this._serializedGdpBaselineCache;
+ // A sparse [x, y, value] list of every land tile's opening production per
+ // head. It never changes, so it is memoised and the delta wire drops it after
+ // the first snapshot a peer receives.
+ _serializeProductionBaseline() {
+ if (this._serializedProductionBaselineCache) return this._serializedProductionBaselineCache;
const result = [];
- if (this.tileGdpBaseline) {
- for (const [k, value] of this.tileGdpBaseline) {
+ if (this.tileProductionBaseline) {
+ for (const [k, value] of this.tileProductionBaseline) {
const coords = parseKey(k);
result.push([coords.x, coords.y, Math.round(value)]);
}
}
- this._serializedGdpBaselineCache = result;
+ this._serializedProductionBaselineCache = result;
return result;
},
+ // Each region's production per head, before its tile modifiers, as
+ // [cityId, value]. Small and world-wide, so every viewer gets it and the tile
+ // panel can explain a tile's figure exactly as the server derived it.
+ _serializeRegionProductionBase() {
+ return this.cities.map((city) => [
+ city.id,
+ roundTo(this._regionProductionPerCapita(city), 4),
+ ]);
+ },
+
// A sparse [x, y, value] list for a per-tile map, used for the war marks.
_serializeTileValues(values) {
const result = [];
@@ -353,6 +414,11 @@ export const serializationMethods = {
income: economy.income,
// The region's own income and building upkeep, for the city budget tab.
budget: this.cityBudgetBreakdown(city, economy),
+ // The private-sector cash the region holds, a basket of currencies, and
+ // what it owes its central bank.
+ cash: this._serializeRegionCash(city),
+ cashValue: this.getRegionCashValue(city),
+ debt: this.getRegionDebt(city),
// The ethnicity of the region's people, for the circle graph.
ethnicMakeup: this.getCityEthnicMakeup(city),
};
@@ -511,6 +577,65 @@ export const serializationMethods = {
return civs.includes(owner) ? owner : civs[0];
},
+ // -------------------------------------------------------- resources --
+
+ // Today's global price and the supply and demand behind it, one entry per
+ // resource. The figures are world-wide, so every viewer sees the same market.
+ _serializeResourceMarket() {
+ return RESOURCE_IDS.map((id) => {
+ const stats = this.resourceMarketStats ? this.resourceMarketStats.get(id) : null;
+ return {
+ id,
+ price: this.getResourcePrice(id),
+ supply: stats ? stats.supply : 0,
+ demand: stats ? stats.demand : 0,
+ };
+ });
+ },
+
+ // Every nation's imports and exports of each resource over the last day, so
+ // the Resources tab can show a viewer's own trade and rank the world's major
+ // exporters and importers. Cross-nation deliveries only.
+ _serializeResourceTrade() {
+ return this.civilisations.map((_, civ) => {
+ const record = this.resourceTrade ? this.resourceTrade.get(civ) : null;
+ const imports = {};
+ const exports = {};
+ for (const id of RESOURCE_IDS) {
+ imports[id] = roundTo(record ? record.imports[id] || 0 : 0, 3);
+ exports[id] = roundTo(record ? record.exports[id] || 0 : 0, 3);
+ }
+ return { civ, imports, exports };
+ });
+ },
+
+ // The viewer's own storage nodes and delivery legs, as [x, y] nodes and
+ // [fromX, fromY, toX, toY, resource, amount] links. A spectator sees none.
+ _serializeResourceGraph(viewerCiv) {
+ if (viewerCiv < 0 || !this.resourceStock) return { nodes: [], links: [] };
+ const nodes = [];
+ for (const [k] of this.resourceStock) {
+ const coords = parseKey(k);
+ const node = this._resourceNodeAt(coords);
+ if (!node || node.civ !== viewerCiv) continue;
+ nodes.push([coords.x, coords.y, node.kind === "city" ? 1 : 0]);
+ }
+ const links = [];
+ for (const link of this.resourceLinks || []) {
+ const to = link.to;
+ if (this.civAt({ x: to[0], y: to[1] }) !== viewerCiv) continue;
+ links.push([
+ link.from[0],
+ link.from[1],
+ link.to[0],
+ link.to[1],
+ link.resource,
+ Math.round(link.amount * 1000) / 1000,
+ ]);
+ }
+ return { nodes, links };
+ },
+
// --------------------------------------------------------- politics --
// Every nation's share of world culture, as a fraction per civilisation.
diff --git a/shared/game_state/statuses.js b/shared/game_state/statuses.js
index bdfc559..42f5a52 100644
--- a/shared/game_state/statuses.js
+++ b/shared/game_state/statuses.js
@@ -1,33 +1,26 @@
-// Supply: encirclement and siege detection, including the maritime exception
-// that keeps a coastal city supplied through allied naval units.
+// Supply: encirclement and siege detection. A tile is cut off when enemy units
+// sit on every land tile beside it and it has no working port to bring supplies
+// in by sea; a port is itself blockaded when enemy warships hold every sea tile
+// around its city.
import { key } from "../hex.js";
import { STATUS_ENCIRCLED, STATUS_BESIEGED } from "../data/statuses.js";
export const statusMethods = {
- // Recomputes the status of every unit and city. The supply search only runs
- // for an occupant that has a hostile unit adjacent, and only once a day (and
- // at setup), so it never touches the snapshot path.
+ // Recomputes the status of every unit and city. A unit cut off is encircled;
+ // a city cut off is besieged. The check is local -- the ring of land tiles
+ // around the occupant and its own port -- so it never walks the whole map.
_refreshStatuses() {
- const components = new Map();
for (const unit of this.units) {
- if (!this._hasAdjacentHostile(unit.coords, unit.civ)) {
- unit.statuses = [];
- continue;
- }
- unit.statuses = this._unitStatuses(unit, this._componentsFor(components, unit.civ));
+ unit.statuses = this._isEncircled(unit.coords, unit.civ) ? [STATUS_ENCIRCLED] : [];
}
for (const city of this.cities) {
- if (!this._hasAdjacentHostile(city.coords, city.civ)) {
- city.statuses = [];
- continue;
- }
- city.statuses = this._cityStatuses(city, this._componentsFor(components, city.civ));
+ city.statuses = this._isEncircled(city.coords, city.civ) ? [STATUS_BESIEGED] : [];
}
},
- // The trigger: encirclement is only evaluated when a hostile unit is next to
- // the tile.
+ // Whether a hostile unit stands next to `coords`. Kept as the cheap trigger
+ // for callers that only care whether a fight is near.
_hasAdjacentHostile(coords, civ) {
for (const neighbour of this._neighbours(coords)) {
if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) return true;
@@ -35,20 +28,60 @@ export const statusMethods = {
return false;
},
- _componentsFor(cache, civ) {
- if (!cache.has(civ)) cache.set(civ, this._landComponents(civ));
- return cache.get(civ);
+ // A tile is encircled when no enemy warship can break the ring: it has no
+ // working port, and every land tile beside it is held by an enemy unit. A
+ // tile with no land neighbours has no ring to close, so it is not encircled.
+ _isEncircled(coords, civ) {
+ if (this._hasSeaSupply(coords, civ)) return false;
+ return this._landRingBlocked(coords, civ);
+ },
+
+ _landRingBlocked(coords, civ) {
+ let land = 0;
+ for (const neighbour of this._neighbours(coords)) {
+ const tile = this.tiles[key(neighbour.x, neighbour.y)];
+ if (!tile || tile.terrainClass !== "Land") continue;
+ land += 1;
+ if (!this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) return false;
+ }
+ return land > 0;
+ },
+
+ // A friendly city's port supplies its own tile by sea, unless the port is
+ // blockaded.
+ _hasSeaSupply(coords, civ) {
+ const city = this.cityAt(coords);
+ if (!city || city.civ !== civ) return false;
+ if (!this.hasCityBuilding(city, "port")) return false;
+ return !this._isPortBlockaded(city);
+ },
+
+ // A port is blockaded when enemy warships hold every sea tile around the city.
+ _isPortBlockaded(city) {
+ let sea = 0;
+ let held = 0;
+ for (const neighbour of this._neighbours(city.coords)) {
+ const tile = this.tiles[key(neighbour.x, neighbour.y)];
+ if (!tile || tile.terrainClass !== "Sea") continue;
+ sea += 1;
+ if (this.unitsAt(neighbour).some(
+ (unit) => unit.civ !== city.civ && this._isNaval(unit))) {
+ held += 1;
+ }
+ }
+ return sea > 0 && held === sea;
},
_isNaval(unit) {
const proto = this.unitProto(unit);
// Aircraft may fly over the sea, but only a hull counts as naval supply.
- return !!(proto && !proto.air && proto.traversableTerrains.includes("Sea"));
+ return !!(proto && !proto.air && (proto.traversableTerrains || []).includes("Sea"));
},
// Land connected components for one civ, with enemy-occupied and enemy-held
// city tiles acting as walls. Each component remembers how many of the civ's
- // cities it contains, which is all the supply check needs.
+ // cities it contains. Used by the unit supply rule to find a clear route to
+ // an allied city.
_landComponents(civ) {
const byTile = new Map();
const cityCountByComp = new Map();
@@ -89,76 +122,6 @@ export const statusMethods = {
return true;
},
- // A city is besieged when it no longer shares a land component with any
- // other city of its civ. A lone city is never besieged.
- _isCityBesieged(city, info) {
- if (info.totalCities < 2) return false;
- const component = info.byTile.get(key(city.coords.x, city.coords.y));
- const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0);
- return count <= 1;
- },
-
- _unitStatuses(unit, info) {
- const city = this.cityAt(unit.coords);
- const inOwnCity = city && city.civ === unit.civ;
- let encircled;
- if (inOwnCity) {
- encircled = this._isCityBesieged(city, info);
- } else {
- const component = info.byTile.get(key(unit.coords.x, unit.coords.y));
- const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0);
- encircled = count === 0;
- }
- const excludeCity = inOwnCity ? city.id : 0;
- if (encircled && this._hasMaritimeRoute(unit.coords, unit.civ, excludeCity)) {
- encircled = false;
- }
- return encircled ? [STATUS_ENCIRCLED] : [];
- },
-
- _cityStatuses(city, info) {
- const besieged = this._isCityBesieged(city, info) &&
- !this._hasMaritimeRoute(city.coords, city.civ, city.id);
- return besieged ? [STATUS_BESIEGED] : [];
- },
-
- // The maritime exception: with an allied naval unit on an adjacent sea tile,
- // a lane is traced across water to a different coastal allied city. Enemy
- // units on the water block it.
- _hasMaritimeRoute(origin, civ, excludeCityId) {
- const visited = new Set();
- let frontier = [];
- for (const neighbour of this._neighbours(origin)) {
- const tile = this.tiles[key(neighbour.x, neighbour.y)];
- if (!tile || tile.terrainClass !== "Sea") continue;
- if (!this.unitsAt(neighbour).some((unit) => unit.civ === civ && this._isNaval(unit))) continue;
- const nk = key(neighbour.x, neighbour.y);
- if (visited.has(nk)) continue;
- visited.add(nk);
- frontier.push(neighbour);
- }
- while (frontier.length > 0) {
- const next = [];
- for (const coords of frontier) {
- for (const neighbour of this._neighbours(coords)) {
- const city = this.cityAt(neighbour);
- if (city && city.civ === civ && city.id !== excludeCityId) return true;
- }
- for (const neighbour of this._neighbours(coords)) {
- const tile = this.tiles[key(neighbour.x, neighbour.y)];
- if (!tile || tile.terrainClass !== "Sea") continue;
- const nk = key(neighbour.x, neighbour.y);
- if (visited.has(nk)) continue;
- if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) continue;
- visited.add(nk);
- next.push(neighbour);
- }
- }
- frontier = next;
- }
- return false;
- },
-
_statusHpLossPerDay(def, inCity) {
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
diff --git a/shared/game_state/warfare.js b/shared/game_state/warfare.js
index fbadc19..65904ea 100644
--- a/shared/game_state/warfare.js
+++ b/shared/game_state/warfare.js
@@ -7,12 +7,13 @@ import { ECONOMY } from "../data/economy.js";
import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js";
import { TRENCH } from "../data/combat.js";
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
+import { protoUpkeep, tileImprovementResourceCost } from "../resources.js";
export const warfareMethods = {
// ----------------------------------------------------------- trenches --
// Infantry dig in on a land tile their nation controls. A trench shelters
- // the garrison from melee and artillery at the cost of local GDP.
+ // the garrison from melee and artillery at the cost of local production.
requestDigTrench(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return false;
@@ -54,9 +55,17 @@ export const warfareMethods = {
if (this.civAt(coords) !== civ) return false;
if (this.tileImprovements.has(k)) return false;
if (proto.coastal && !this._isCoastalTile(coords)) return false;
+ // Resource producers feed the map, not a city: they stand on open land.
+ if (proto.resource && this.cityAt(coords)) return false;
+ if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
const gdp = this.getPlayerGdp(civ);
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
- if (!this._spendBudget(civ, proto.buildCost)) return false;
+ if (!this._spendBudget(civ, proto.buildCost, "tilebuild")) return false;
+ const materials = tileImprovementResourceCost(proto, proto.buildCost);
+ if (!this._payConstructionResources(civ, coords, materials, "tilebuild")) {
+ this._refundBudget(civ, proto.buildCost, "tilebuild");
+ return false;
+ }
const work = {
coords: { x: coords.x, y: coords.y },
id,
@@ -189,6 +198,8 @@ export const warfareMethods = {
return true;
}
this.tileImprovementHp.set(k, hp);
+ // The damage leaves a steel debt: the work heals only once it is paid off.
+ this._recordRepairDebt(coords, amount);
return true;
},
@@ -207,13 +218,14 @@ export const warfareMethods = {
return true;
},
- // The hourly maintenance of every military improvement a nation holds.
+ // The daily steel every military and resource improvement a nation holds
+ // wears out. Fuel and operating energy are charged through the grid instead.
getTileImprovementUpkeep(civ) {
let total = 0;
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
- if (proto) total += proto.maintenance;
+ if (proto) total += protoUpkeep(proto, proto.buildCost || 0).steel;
}
return total;
},
@@ -224,12 +236,23 @@ export const warfareMethods = {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!proto) continue;
- const entry = byType.get(id) || { label: proto.name, amount: 0, count: 0 };
- entry.amount += proto.maintenance;
+ const resources = protoUpkeep(proto, proto.buildCost || 0);
+ const amount = this._upkeepCost(resources);
+ if (amount <= 0) continue;
+ const entry = byType.get(id) || {
+ label: proto.name,
+ amount: 0,
+ resources: { steel: 0, energy: 0, hightech: 0 },
+ count: 0,
+ };
+ entry.amount += amount;
+ entry.resources.steel += resources.steel;
+ entry.resources.energy += resources.energy;
+ entry.resources.hightech += resources.hightech;
entry.count += 1;
byType.set(id, entry);
}
- return Array.from(byType.values()).filter((entry) => entry.amount > 0);
+ return Array.from(byType.values());
},
// ------------------------------------------------------ transports --
diff --git a/shared/game_state/world.js b/shared/game_state/world.js
index 9d0a24e..c281272 100644
--- a/shared/game_state/world.js
+++ b/shared/game_state/world.js
@@ -3,6 +3,7 @@
// terrain predicates the rest of the model leans on.
import { ECONOMY } from "../data/economy.js";
+import { RESOURCE_RULES } from "../data/resources.js";
import { ROADS } from "../data/roads.js";
import { transportImprovement } from "../data/improvements.js";
import { buildRoadNetwork } from "../roads.js";
@@ -82,6 +83,7 @@ export const worldMethods = {
: this._placeCities(capitals);
for (const site of sites) this._spawnCity(site);
this._rebuildCityIndex();
+ this._seedCityPorts();
this._generateRoads(cached ? cached.roads : null);
if (!cached) {
cachePut(settlementCache, cacheKey, {
@@ -98,6 +100,18 @@ export const worldMethods = {
this._initPolitics();
this._generateEthnicMakeup();
this._balanceStartingEconomy();
+ // Every nation opens with the resource works its economy needs, laid out
+ // before the budgets are founded so the opening treasury already reflects
+ // them.
+ this._seedResourceBuildings();
+ // The terrain and where the starter industry landed give each region a
+ // different income per head. Unworked land is not what makes a region rich
+ // (food comes from the land, not the headcount), so people now move out of
+ // the poor regions until every region of a nation is level.
+ this._equalizeRegionIncomes();
+ // The starter industry lifts raw GDP unevenly; restore the equal starting
+ // production per head without disturbing the grid demand the seeding planned.
+ this._rebalanceProduction();
// Budgets are founded once the balanced economy is known, so every nation
// opens on 1 January 2000 with a tenth of its own starting GDP.
this._initBudgets();
@@ -151,6 +165,17 @@ export const worldMethods = {
}
},
+ // Every naval city opens the game with a port. It is the only way a nation can
+ // reach resources on other continents, so a coastal city is never landlocked
+ // in trade from the first turn.
+ _seedCityPorts() {
+ const index = this.protoBuildings.findIndex((proto) => proto.id === "port");
+ if (index < 0) return;
+ for (const city of this.cities) {
+ if (this.isCoastalCity(city)) city.buildings[index] = 1;
+ }
+ },
+
// Picks the civilisation's next city name, skipping any a city elsewhere has
// already taken, and falls back to a numbered name so the whole world never
// has two cities called the same thing.
@@ -221,6 +246,9 @@ export const worldMethods = {
// A transport's cargo and any boarding under way.
cargo: [],
embarking: null,
+ // A land unit's carried food, in days. Air and naval units use their own
+ // endurance models instead.
+ foodDays: RESOURCE_RULES.unitSupplyDays,
};
if (proto.air) {
unit.homeCityId = homeCityId;
@@ -257,13 +285,13 @@ export const worldMethods = {
// Map generation should not hand one civilisation a richer start than
// another. Terrain makes the raw territories unequal, so after the world is
// populated we scale each civ's population to the common mean and then its
- // GDP per capita to the mean of the raw per-capita figures. Total population
+ // production per head to the mean of the raw figures. Total population
// and total GDP are preserved; from here on the factors are constants and
// terrain, buildings, technology and war move the numbers normally.
_balanceStartingEconomy() {
const count = this.civilisations.length;
this._populationBalance = new Map();
- this._perCapitaBalance = new Map();
+ this._productionBalance = new Map();
if (count <= 1) return;
const bases = [];
let totalPopulation = 0;
@@ -276,16 +304,36 @@ export const worldMethods = {
}
if (totalPopulation <= 0) return;
const targetPopulation = totalPopulation / count;
- const targetPerCapita = totalGdp / totalPopulation;
+ const targetPerHead = totalGdp / totalPopulation;
+ // Kept so the production balance can be restored once the starter industry
+ // has been laid out and the raw figures have moved.
+ this._targetProductionPerHead = targetPerHead;
for (let i = 0; i < count; i++) {
const base = bases[i];
if (base.population <= 0) continue;
- const perCapita = base.gdp / base.population;
+ const perHead = base.gdp / base.population;
this._populationBalance.set(i, targetPopulation / base.population);
- this._perCapitaBalance.set(i, perCapita > 0 ? targetPerCapita / perCapita : 1);
+ this._productionBalance.set(i, perHead > 0 ? targetPerHead / perHead : 1);
}
},
+ // Seeding the starter resource works changes every nation's raw GDP, but the
+ // production balance must still hold: nations should not open richer just
+ // because their land happens to host more industry. Re-derive the production
+ // factor from the post-seeding figures against the same target, which also
+ // leaves each nation's balanced GDP (and so its grid demand) exactly where
+ // the seeding planned it.
+ _rebalanceProduction() {
+ if (!(this._targetProductionPerHead > 0)) return;
+ for (let i = 0; i < this.civilisations.length; i++) {
+ const base = this._basePlayerAggregates(i);
+ if (base.population <= 0) continue;
+ const perHead = base.gdp / base.population;
+ this._productionBalance.set(i, perHead > 0 ? this._targetProductionPerHead / perHead : 1);
+ }
+ this._gdpPerCapitaCache.clear();
+ },
+
_isLand(coords) {
const tile = this.tiles[key(coords.x, coords.y)];
return !!tile && tile.terrainClass === "Land";
diff --git a/shared/resources.js b/shared/resources.js
new file mode 100644
index 0000000..3a79bcf
--- /dev/null
+++ b/shared/resources.js
@@ -0,0 +1,225 @@
+// Pure resource maths shared by the server (which simulates the economy) and
+// the browser (which explains it). Everything here is framework-free and takes
+// plain numbers, so both sides derive identical figures from the same rules in
+// shared/data/resources.js.
+
+import {
+ RESOURCE_IDS,
+ RESOURCE_RULES,
+ RESOURCE_MARKET,
+ RESOURCE_MARKET_BASE,
+ resourceById,
+} from "./data/resources.js";
+import { EFFECT_RESEARCH } from "./data/effects.js";
+
+const DAYS_PER_YEAR = 365;
+
+// --------------------------------------------------------------- formatting --
+
+// Energy reads in kWh, MWh, GWh or TWh, whichever keeps the figure short.
+export function formatEnergy(kwh) {
+ const value = Math.max(0, kwh);
+ if (value >= 1e9) return `${trim(value / 1e9)} TWh`;
+ if (value >= 1e6) return `${trim(value / 1e6)} GWh`;
+ if (value >= 1e3) return `${trim(value / 1e3)} MWh`;
+ return `${trim(value)} kWh`;
+}
+
+// A material in its own friendly unit: tonnes step up through kt and Mt,
+// carats through kct, high-tech through ku.
+export function formatResourceAmount(id, value) {
+ if (id === "energy") return formatEnergy(value);
+ const amount = Math.max(0, value);
+ let unit = resourceById(id) ? resourceById(id).unit : "";
+ let scaled = amount;
+ let prefix = "";
+ if (amount >= 1e9) {
+ scaled = amount / 1e9;
+ prefix = "M";
+ } else if (amount >= 1e6) {
+ scaled = amount / 1e6;
+ prefix = "k";
+ }
+ return `${trim(scaled)} ${prefix}${unit}`;
+}
+
+function trim(value) {
+ if (value >= 100) return String(Math.round(value));
+ if (value >= 10) return value.toFixed(1).replace(/\.0$/, "");
+ return value.toFixed(2).replace(/\.?0+$/, "");
+}
+
+// --------------------------------------------------------------- per-person --
+
+export function foodNeedPerDay(population) {
+ return Math.max(0, population) * RESOURCE_RULES.foodPerPersonPerDay;
+}
+
+export function steelNeedPerDay(population) {
+ return Math.max(0, population) * RESOURCE_RULES.steelPerPersonPerDay;
+}
+
+export function luxuryNeedPerDay(population) {
+ return Math.max(0, population) * RESOURCE_RULES.luxuryPerPersonPerDay;
+}
+
+// What one worked land tile grows in a day. Food comes from the land, not from
+// how many people live on it: a farm feeds whoever works it, so moving people
+// between regions changes only the income per head, never the harvest. A tile
+// feeds roughly `ECONOMY.basePopulation` people at factor 1.
+export function tileFoodOutput(tile) {
+ const factor = RESOURCE_RULES.tileFoodFactor[tile.terrainType] ?? 1;
+ return RESOURCE_RULES.foodPerTilePerDay * factor;
+}
+
+// How much food a city wants in store before the coming month: a month at the
+// current appetite, with a 130% cushion.
+export function foodMonthlyTarget(population) {
+ return foodNeedPerDay(population) *
+ RESOURCE_RULES.daysPerMonth *
+ RESOURCE_RULES.foodMonthlyBuffer;
+}
+
+// A city's baseline electric appetite, kWh a day, from its yearly GDP.
+export function cityEnergyPerDay(gdp) {
+ return Math.max(0, gdp) * RESOURCE_RULES.gdpEnergyIntensity / DAYS_PER_YEAR;
+}
+
+// What one building level, unit or tile work wears out in a day, from its money
+// value. Advanced things also burn high-tech. Buildings pass their level's cost
+// (so upkeep grows with the level), units their training cost.
+export function upkeepResources(value, advanced = false) {
+ const amount = Math.max(0, value);
+ return {
+ steel: amount * RESOURCE_RULES.upkeepSteelPerEuro,
+ energy: amount * RESOURCE_RULES.upkeepEnergyPerEuro,
+ hightech: advanced ? amount * RESOURCE_RULES.upkeepHighTechPerEuro : 0,
+ };
+}
+
+// The daily upkeep of a catalogue entry. An explicit `materialUpkeep` override
+// wins -- guns and aircraft are cheap parked and costly when fired, so their
+// standing bill is set by hand -- otherwise it scales with the entry's value.
+export function protoUpkeep(proto, value) {
+ if (proto && proto.materialUpkeep) {
+ return {
+ steel: proto.materialUpkeep.steel || 0,
+ energy: proto.materialUpkeep.energy || 0,
+ hightech: proto.materialUpkeep.hightech || 0,
+ };
+ }
+ return upkeepResources(value, !!(proto && proto.advanced));
+}
+
+// The materials and power a tile improvement spends to be built: steel in
+// proportion to its money cost, the electricity that steel embodies, and
+// high-tech for an advanced work. Shared so the server's bill and the browser's
+// cost buttons agree on the same figures.
+export function tileImprovementResourceCost(proto, buildCost = null) {
+ const cost = Math.max(0, buildCost !== null ? buildCost : ((proto && proto.buildCost) || 0));
+ return {
+ steel: cost * RESOURCE_RULES.steelPerBudgetEuro,
+ energy: cost * RESOURCE_RULES.constructionEnergyPerBudgetEuro,
+ hightech: proto && proto.advanced ? RESOURCE_RULES.constructionHighTechPerLevel : 0,
+ };
+}
+
+// The steel and high-tech a building or unit spends to be built: steel in
+// proportion to its money cost, high-tech for an advanced item (more with every
+// level). Mirrors the server's construction bill so the browser can quote it at
+// today's prices.
+export function constructionResourceCost(proto, level = 0, buildCost = null) {
+ const cost = buildCost !== null ? buildCost : ((proto && (proto.buildCost || proto.baseCost)) || 0);
+ const advanced = !!(proto && (proto.advanced ||
+ (proto.effects || []).some((e) => e.stat === EFFECT_RESEARCH)));
+ return {
+ steel: Math.max(0, cost) * RESOURCE_RULES.steelPerBudgetEuro,
+ hightech: advanced ? RESOURCE_RULES.constructionHighTechPerLevel * (level + 1) : 0,
+ };
+}
+
+// Today's global price index: the average ratio of each resource's current
+// price to its base. One figure lets a money cost with no material bill of its
+// own be quoted at the market.
+export function marketPriceIndex(prices) {
+ let total = 0;
+ let count = 0;
+ for (const id of RESOURCE_IDS) {
+ const base = RESOURCE_MARKET_BASE[id];
+ if (!(base > 0)) continue;
+ const price = prices && prices[id] !== undefined ? prices[id] : base;
+ total += price / base;
+ count += 1;
+ }
+ return count > 0 ? total / count : 1;
+}
+
+// The market value of a resource bill at the given prices, a resource at a time.
+export function resourceMarketValue(resources, prices) {
+ let total = 0;
+ for (const id of RESOURCE_IDS) {
+ const amount = (resources && resources[id]) || 0;
+ if (!(amount > 0)) continue;
+ const price = prices && prices[id] !== undefined ? prices[id] : RESOURCE_MARKET_BASE[id];
+ total += amount * (price || 0);
+ }
+ return total;
+}
+
+// The energy a material producer burns to make its output: the energy-per-unit
+// ratio from the rules, whichever resource it makes.
+export function producerEnergyPerDay(id, outputPerDay) {
+ if (id === "steel") return outputPerDay * RESOURCE_RULES.energyPerSteelTonne;
+ if (id === "food") return outputPerDay * RESOURCE_RULES.energyPerFoodTonne;
+ if (id === "hightech") return outputPerDay * RESOURCE_RULES.energyPerHighTechUnit;
+ return 0;
+}
+
+// ---------------------------------------------------------------- delivery --
+
+// The kWh of transport energy one material tonne costs over `tiles` tiles in the
+// given mode. A material tonne and an energy tonne (10 GWh) share the formula.
+export function deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost = 1) {
+ const d = RESOURCE_RULES.deliveryEnergy;
+ const perTile = mode === "train"
+ ? d.train
+ : mode === "ship"
+ ? d.ship
+ : mode === "road"
+ ? d.truckRoad
+ : d.truckOffRoad * Math.max(tileMovementCost, 0);
+ return tonnes * Math.max(tiles, 0) * perTile;
+}
+
+// The energy cost of delivering `amount` of a resource over `tiles`. Every
+// resource carries a mass per canonical unit, so a carat or a chip is charged
+// for the tonnes it actually weighs rather than as if it were a tonne.
+export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementCost = 1) {
+ const proto = resourceById(id);
+ const tonnes = amount * (proto ? proto.tonnesPerUnit : 1);
+ return deliveryEnergyKwh(tonnes, tiles, mode, tileMovementCost);
+}
+
+// The transport mode a tile offers, most efficient first: rail beats road, and
+// an unimproved tile falls back to an off-road truck. Sea legs ship.
+export function transportModeForTile(tile, road, railway, sea) {
+ if (sea || (tile && tile.terrainClass === "Sea")) return "ship";
+ if (railway) return "train";
+ if (road) return "road";
+ return "truck";
+}
+
+// ----------------------------------------------------------------- market --
+
+// Moves a price toward what the day's world-wide supply and demand imply. A
+// glut pushes it down, a shortage up, and the clamp keeps the market sane. A
+// zero-supply day is treated as the sharpest shortage.
+export function nextMarketPrice(basePrice, price, supply, demand) {
+ if (!(demand > 0)) return basePrice;
+ const ratio = supply > 0 ? demand / supply : RESOURCE_MARKET.maxMultiplier;
+ const pressure = (ratio - 1) * RESOURCE_MARKET.adjustment;
+ const moved = price * (1 + pressure);
+ const min = basePrice * RESOURCE_MARKET.minMultiplier;
+ const max = basePrice * RESOURCE_MARKET.maxMultiplier;
+ return Math.max(min, Math.min(max, moved));
+}
diff --git a/shared/rules.js b/shared/rules.js
index e940fd2..043247f 100644
--- a/shared/rules.js
+++ b/shared/rules.js
@@ -107,13 +107,13 @@ export function unitDefense(baseDefense, tile, hasCity) {
return baseDefense * defenseMultiplier(tile, hasCity);
}
-// Every GDP-per-capita modifier of a tile, in display order. `tile` supplies the
+// Every production modifier of a tile, in display order. `tile` supplies the
// terrain multiplier and its name; `cityKind` is "capital", "city" or null and
// `nextToCity` is true when the tile borders one of its owner's cities. The
// pillage penalty and battle deficit are the lasting marks of war. Shared by the
-// server (which derives each tile's GDP) and the browser (which explains it), so
-// the numbers always agree.
-export function gdpPerCapitaFactors(tile, {
+// server (which derives each tile's production) and the browser (which explains
+// it), so the numbers always agree.
+export function productionFactors(tile, {
cityKind = null,
nextToCity = false,
pillagePenalty = 1,
@@ -122,28 +122,28 @@ export function gdpPerCapitaFactors(tile, {
regionalMultiplier = 1,
regionalLabel = null,
nationalMultiplier = 1,
- portGdp = 0,
+ portProduction = 0,
naturalGrowth = 0,
} = {}) {
const factors = [];
- const terrainMultiplier = tile ? tile.gdpMultiplier || 0 : 0;
+ const terrainMultiplier = tile ? tile.productionMultiplier || 0 : 0;
factors.push({ label: tile ? tile.terrainType : "Unknown", multiplier: terrainMultiplier });
if (cityKind === "capital") {
- factors.push({ label: "Capital", multiplier: ECONOMY.capitalTileGdpMultiplier });
+ factors.push({ label: "Capital", multiplier: ECONOMY.capitalTileProductionMultiplier });
} else if (cityKind === "city") {
- factors.push({ label: "City", multiplier: ECONOMY.cityTileGdpMultiplier });
+ factors.push({ label: "City", multiplier: ECONOMY.cityTileProductionMultiplier });
} else if (nextToCity) {
- factors.push({ label: "Near city", multiplier: 1 + ECONOMY.citySurroundingGdpBonus });
+ factors.push({ label: "Near city", multiplier: 1 + ECONOMY.citySurroundingProductionBonus });
}
if (pillagePenalty !== 1) {
factors.push({ label: "Pillaging", multiplier: pillagePenalty });
}
if (trench) {
- factors.push({ label: "Trenches", multiplier: TRENCH.gdpMultiplier });
+ factors.push({ label: "Trenches", multiplier: TRENCH.productionMultiplier });
}
// City improvements only shape the region their city controls, so a tile's
- // GDP per capita carries its region's multiplier plus the nation-wide one
- // from government and research. Both are factors on the per-capita figure,
+ // production carries its region's multiplier plus the nation-wide one
+ // from government and research. Both are factors on the production figure,
// never an adjustment to the final GDP. They are applied before the absolute
// war damage below, so a scar stays proportional to the tile's productivity.
if (regionalMultiplier !== 1) {
@@ -155,13 +155,14 @@ export function gdpPerCapitaFactors(tile, {
if (nationalMultiplier !== 1) {
factors.push({ label: "National modifiers", multiplier: nationalMultiplier });
}
- // A warm-water port adds a flat per-capita figure to its whole region, scaled
- // by the sea tiles beside the city, before war damage is taken off.
- if (portGdp > 0) {
- factors.push({ label: "Port", amount: portGdp });
+ // A warm-water port adds a flat production-per-head figure to its whole
+ // region, scaled by the sea tiles beside the city, before war damage is taken
+ // off.
+ if (portProduction > 0) {
+ factors.push({ label: "Port", amount: portProduction });
}
- // Natural growth is a flat per-capita gain added on top of the tile's
- // productivity, growing by a constant amount each day.
+ // Natural growth is a flat production-per-head gain added on top of the
+ // tile's productivity, growing by a constant amount each day.
if (naturalGrowth > 0) {
factors.push({ label: "Natural growth", amount: naturalGrowth });
}
@@ -171,9 +172,9 @@ export function gdpPerCapitaFactors(tile, {
return factors;
}
-// Applies the `gdpPerCapitaFactors` list to `base`, in order, never falling
+// Applies the `productionFactors` list to `base`, in order, never falling
// below zero.
-export function gdpPerCapitaFromFactors(base, factors) {
+export function productionFromFactors(base, factors) {
let total = base;
for (const factor of factors) {
if (factor.amount !== undefined) total += factor.amount;
@@ -181,3 +182,14 @@ export function gdpPerCapitaFromFactors(base, factors) {
}
return total > 0 ? total : 0;
}
+
+// The multiplier product of a `productionFactors` list, ignoring the absolute
+// per-head terms (a port's flat production, natural growth, battle damage).
+// This is what scales a tile's physical output.
+export function productionMultiplier(factors) {
+ let total = 1;
+ for (const factor of factors) {
+ if (factor.multiplier !== undefined) total *= factor.multiplier;
+ }
+ return total > 0 ? total : 0;
+}
diff --git a/shared/terrain_stats.js b/shared/terrain_stats.js
index 660499a..69a36a4 100644
--- a/shared/terrain_stats.js
+++ b/shared/terrain_stats.js
@@ -21,8 +21,8 @@ export class TerrainStats {
return this._value(coords, "populationMultiplier");
}
- gdpMultiplier(coords) {
- return this._value(coords, "gdpMultiplier");
+ productionMultiplier(coords) {
+ return this._value(coords, "productionMultiplier");
}
movementCostMultiplier(coords) {
diff --git a/tests/air_test.js b/tests/air_test.js
index 5ca4d9a..28d3567 100644
--- a/tests/air_test.js
+++ b/tests/air_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { parseKey, key } from "../shared/hex.js";
-import { AIR_STRIKE } from "../shared/data.js";
+import { AIR_STRIKE, RESOURCE_RULES } from "../shared/data.js";
const BIG_BUDGET = 1.0e15;
@@ -97,8 +97,8 @@ export class AirTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const shipyard = buildingIndex(state, "shipyard");
const city = coastalCity(state, 0);
- this.assertFalse(state.requestBuild(city.id, shipyard), "no port, no shipyard");
- giveBuilding(state, city, "port");
+ // Every naval city opens the game with a port.
+ this.assertTrue(state.hasCityBuilding(city, "port"), "a naval city starts with a port");
this.assertTrue(state.requestBuild(city.id, shipyard), "a port unlocks the shipyard");
// Even with a port, an inland city has no water to launch from.
@@ -160,13 +160,18 @@ export class AirTest extends TestCase {
this.assertNotNull(target, "there is a target tile within range");
const defender = state._spawnUnit(target, 1, protoUnit(state, "modern_infantry"));
+ const totalTech = () => state.cities.filter((c) => c.civ === 0)
+ .reduce((sum, c) => sum + (state.getCityResourceStock(c).hightech || 0), 0);
+ const beforeTech = totalTech();
+
this.assertTrue(state.requestAirStrike([unit.id], defender.coords), "the strike is accepted");
this.assertEqual(unit.coords.x, city.coords.x, "it has not taken off yet");
+ // A sortie is paid for in munitions, not money.
this.assertApprox(
- state.getBudget(0),
- BIG_BUDGET - AIR_STRIKE.cost,
+ beforeTech - totalTech(),
+ RESOURCE_RULES.combatConsumption.airStrike.hightech,
1e-6,
- "the strike was paid for up front"
+ "the sortie spent its high-tech up front"
);
state.advanceHour();
diff --git a/tests/app_test.js b/tests/app_test.js
index e46ab62..d9d8778 100644
--- a/tests/app_test.js
+++ b/tests/app_test.js
@@ -35,18 +35,20 @@ export class AppTest extends TestCase {
joinSocket.simulateMessage({ t: "assigned", playerId: 2, admin: false, name: "Carol" });
this.assertFalse(env.$("#screen-game").hasClass("active"), "no game screen before picking");
+ const state = smallState();
+ const britain = state.civilisations.findIndex((civ) => civ.id === "britain");
joinSocket.simulateMessage({
t: "choose_civ",
- civs: [{ index: 1, id: "britain", name: "Britain" }],
+ civs: [{ index: britain, id: "britain", name: "Britain" }],
});
this.assertTrue(env.$("#screen-choose").hasClass("active"));
this.assertSize(env.$("#choose-civs li"), 1);
env.$("#choose-civs li").first().click();
- this.assertEqual(joinSocket.lastSent(), { t: "claim_civ", civ: 1 });
+ this.assertEqual(joinSocket.lastSent(), { t: "claim_civ", civ: britain });
- joinSocket.simulateMessage({ t: "civ_assigned", civ: 1 });
+ joinSocket.simulateMessage({ t: "civ_assigned", civ: britain });
this.assertTrue(env.$("#screen-game").hasClass("active"));
- joinSocket.simulateMessage({ t: "game_state", state: smallState().snapshot(1) });
+ joinSocket.simulateMessage({ t: "game_state", state: state.snapshot(britain) });
this.assertEqual(env.$("#civ-name").text(), "Britain");
this.assertFalse(env.$("#stat-pop").text() === "0");
} finally {
@@ -114,19 +116,18 @@ export class AppTest extends TestCase {
this.assertEqual(socket.lastSent(), { t: "login", name: "adrien", password: "admin" });
socket.simulateMessage({ t: "assigned", playerId: 1, admin: true, name: "adrien" });
+ const state = smallState();
+ const france = state.civilisations.findIndex((civ) => civ.id === "france");
socket.simulateMessage({
t: "choose_civ",
- civs: [
- { index: 4, id: "france", name: "France" },
- { index: 1, id: "britain", name: "Britain" },
- ],
+ civs: [{ index: france, id: "france", name: "France" }],
});
- this.assertEqual(socket.lastSent(), { t: "claim_civ", civ: 4 }, "France is claimed");
+ this.assertEqual(socket.lastSent(), { t: "claim_civ", civ: france }, "France is claimed");
this.assertFalse(env.$("#screen-choose").hasClass("active"), "the picker is skipped");
- socket.simulateMessage({ t: "civ_assigned", civ: 4 });
+ socket.simulateMessage({ t: "civ_assigned", civ: france });
this.assertTrue(env.$("#screen-game").hasClass("active"));
- socket.simulateMessage({ t: "game_state", state: smallState().snapshot(0) });
+ socket.simulateMessage({ t: "game_state", state: state.snapshot(france) });
this.assertEqual(env.$("#civ-name").text(), "France");
} finally {
teardownDom(env);
diff --git a/tests/budget_test.js b/tests/budget_test.js
new file mode 100644
index 0000000..66e14ce
--- /dev/null
+++ b/tests/budget_test.js
@@ -0,0 +1,139 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState } from "./framework/helpers.js";
+import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
+import { BUDGET_HISTORY_MONTHS } from "../shared/game_state/budget.js";
+
+export class BudgetTest extends TestCase {
+ test_the_opening_month_is_the_only_report() {
+ const state = smallState();
+ const months = state.budgetMonthReports(0);
+ this.assertSize(months, 1, "only the live month exists at the start");
+ this.assertTrue(months[0].isCurrent, "the live month is flagged current");
+ this.assertEqual(months[0].label, "January 2000");
+ this.assertEqual(months[0].expenses.length, 0, "nothing has been spent yet");
+ this.assertEqual(months[0].net, 0, "the month opens balanced");
+ }
+
+ test_spending_is_filed_under_its_category() {
+ const state = smallState();
+ state._spendBudget(0, 1_000, "training");
+ state._spendBudget(0, 4_000, "construction");
+ state._recordBudgetCash(0, "income", 9_000);
+ const report = state.budgetMonthReports(0).at(-1);
+ const byId = new Map(report.expenses.map((entry) => [entry.id, entry]));
+ this.assertApprox(byId.get("training").cash, 1_000);
+ this.assertApprox(byId.get("construction").cash, 4_000);
+ this.assertApprox(report.income, 9_000);
+ this.assertApprox(report.totalCash, 5_000);
+ this.assertApprox(report.net, 4_000);
+ }
+
+ test_a_spend_on_an_empty_treasury_becomes_debt_and_is_recorded() {
+ const state = smallState();
+ state.budgets.set(0, 0);
+ // The government always pays; the overdraft is the central bank's loan.
+ this.assertTrue(state._spendBudget(0, 500, "training"));
+ this.assertLess(state.getBudget(0), 0, "the treasury went into the red");
+ const report = state.budgetMonthReports(0).at(-1);
+ const training = report.expenses.find((entry) => entry.id === "training");
+ this.assertApprox(training.cash, 500);
+ }
+
+ test_a_refund_cancels_the_category() {
+ const state = smallState();
+ state._spendBudget(0, 1_000, "construction");
+ state._refundBudget(0, 1_000, "construction");
+ const report = state.budgetMonthReports(0).at(-1);
+ this.assertEqual(report.expenses.length, 0, "a refunded order does not linger");
+ this.assertEqual(report.net, 0);
+ }
+
+ test_daily_upkeep_records_cash_and_market_value() {
+ const state = smallState();
+ state.advanceHour();
+ for (let i = 0; i < 23; i++) state.advanceHour();
+ const report = state.budgetMonthReports(0).at(-1);
+ const byId = new Map(report.expenses.map((entry) => [entry.id, entry]));
+ for (const id of ["buildings", "units", "works", "transport"]) {
+ const entry = byId.get(id);
+ this.assertNotNull(entry, `${id} upkeep is recorded`);
+ this.assertTrue(entry.economic >= entry.cash - 1e-6, `${id} market value covers its cash`);
+ }
+ }
+
+ test_rolling_a_month_freezes_a_digest_and_resets() {
+ const state = smallState();
+ state._spendBudget(0, 2_000, "training");
+ // January 2000 has 31 days.
+ for (let i = 0; i < 31 * HOURS_PER_DAY; i++) state.tickHour();
+ const months = state.budgetMonthReports(0);
+ this.assertSize(months, 2, "January is frozen behind the live February");
+ this.assertEqual(months[0].label, "January 2000");
+ this.assertTrue(!months[0].isCurrent, "the frozen month is not current");
+ this.assertEqual(months[1].label, "February 2000");
+ this.assertTrue(months[1].isCurrent, "the live month is February");
+ const training = months[0].expenses.find((entry) => entry.id === "training");
+ this.assertApprox(training.cash, 2_000, 1e-6);
+ this.assertEqual(
+ months[1].expenses.find((entry) => entry.id === "training"),
+ undefined,
+ "the new month starts empty"
+ );
+ }
+
+ test_the_history_is_capped() {
+ const state = smallState();
+ for (let i = 0; i < BUDGET_HISTORY_MONTHS + 5; i++) state._rolloverBudget(24000 + i);
+ this.assertSize(state.budgetHistory, BUDGET_HISTORY_MONTHS, "only two years are kept");
+ }
+
+ test_resource_trade_is_mirrored_into_the_month() {
+ const state = smallState();
+ state._recordResourceSpend(0, "goods", 5_000, "steel");
+ state._recordResourceEarn(0, "steel", 2_000);
+ const report = state.budgetMonthReports(0).at(-1);
+ const trade = report.resourceTrade.find((entry) => entry.id === "steel");
+ this.assertNotNull(trade);
+ this.assertApprox(trade.bought, 5_000);
+ this.assertApprox(trade.sold, 2_000);
+ }
+
+ test_expense_rows_carry_their_resource_detail() {
+ const state = smallState();
+ state._recordBudgetCash(0, "buildings", -500);
+ state._recordBudgetCategoryResource(0, "buildings", "steel", 300);
+ state._recordBudgetCategoryResource(0, "buildings", "hightech", 200);
+ const report = state.budgetMonthReports(0).at(-1);
+ const buildings = report.expenses.find((entry) => entry.id === "buildings");
+ this.assertApprox(buildings.cash, 500);
+ this.assertEqual(buildings.buys.length, 2, "both resources are listed");
+ const steel = buildings.buys.find((entry) => entry[0] === "steel");
+ this.assertApprox(steel[1], 300);
+ }
+
+ test_upkeep_records_the_resources_it_bought() {
+ const state = smallState();
+ // Empty the stores so the day's upkeep has to be bought on the market.
+ for (const city of state.cities) {
+ const store = state.getCityResourceStock(city);
+ store.steel = 0;
+ store.hightech = 0;
+ }
+ state.budgets.set(0, 1.0e15);
+ for (let i = 0; i < HOURS_PER_DAY; i++) state.tickHour();
+ const report = state.budgetMonthReports(0).at(-1);
+ const buildings = report.expenses.find((entry) => entry.id === "buildings");
+ this.assertNotNull(buildings, "building upkeep is recorded");
+ this.assertGreater(buildings.buys.length, 0, "the row names the resource it bought");
+ }
+
+ test_the_snapshot_ships_the_month_reports() {
+ const state = smallState();
+ const snapshot = state.snapshot(0);
+ const months = snapshot.viewerStats.budgetMonths;
+ this.assertGreaterOrEqual(months.length, 1);
+ const current = months[months.length - 1];
+ this.assertTrue(current.isCurrent, "the last shipped report is the live month");
+ this.assertTrue(Array.isArray(current.expenses), "the report carries its expenses");
+ }
+}
diff --git a/tests/combat_test.js b/tests/combat_test.js
index 5f569fa..26120ac 100644
--- a/tests/combat_test.js
+++ b/tests/combat_test.js
@@ -108,21 +108,12 @@ export class CombatTest extends TestCase {
test_battle_triples_the_fighting_unit_upkeep() {
const state = smallState();
const { attacker } = engagement(state);
- const proto = state.protoUnits[attacker.proto];
- const idleUpkeep = state.getPlayerUpkeep(0) - proto.upkeep * 2;
- const idleUnits = state.budgetBreakdown(0).unitUpkeep.total - proto.upkeep * 2;
- this.assertApprox(
- state.getPlayerUpkeep(0) - idleUpkeep,
- proto.upkeep * 2,
- 1e-9,
- "the battle premium is twice the base upkeep"
- );
- this.assertApprox(
- state.budgetBreakdown(0).unitUpkeep.total - idleUnits,
- proto.upkeep * 2,
- 1e-9,
- "the budget breakdown reflects the premium"
+ const battle = state._battleUnitIds();
+ this.assertTrue(battle.has(attacker.id), "the attacker is in battle");
+ const multiplier = state._statusUpkeepMultiplier(
+ state._effectiveStatuses(attacker, true)
);
+ this.assertGreater(multiplier, 1, "a unit in battle burns more material upkeep");
}
test_a_blocked_move_with_no_route_is_abandoned() {
@@ -434,7 +425,7 @@ export class CombatTest extends TestCase {
);
this.assertApprox(
state.getTileGdpPerCapita(b),
- perCapita * (1 - BATTLE.gdpPerCapitaLossPerDay),
+ perCapita * (1 - BATTLE.productionLossPerDay),
1e-6,
"the tile loses GDP per capita"
);
@@ -446,19 +437,19 @@ export class CombatTest extends TestCase {
const k = key(b.x, b.y);
const perCapita = state.getTileGdpPerCapita(b);
state._tickBattleTiles();
- const deficit = state.tileBattleGdpDeficit.get(k);
- this.assertApprox(deficit, perCapita * BATTLE.gdpPerCapitaLossPerDay, 1e-6);
+ const deficit = state.tileBattleProductionDeficit.get(k);
+ this.assertApprox(deficit, perCapita * BATTLE.productionLossPerDay, 1e-6);
const start = state.tileBattleRecoveryStart.get(k);
this.assertEqual(start, BATTLE.recoveryDelayDays * DAY, "recovery waits thirty days");
state.totalHours = start - 1;
- state._tickGdpRecovery();
- this.assertApprox(state.tileBattleGdpDeficit.get(k), deficit, 1e-9, "nothing recovers early");
+ state._tickProductionRecovery();
+ this.assertApprox(state.tileBattleProductionDeficit.get(k), deficit, 1e-9, "nothing recovers early");
state.totalHours = start;
- state._tickGdpRecovery();
+ state._tickProductionRecovery();
this.assertApprox(
- state.tileBattleGdpDeficit.get(k),
+ state.tileBattleProductionDeficit.get(k),
deficit - BATTLE.recoveryPerDay,
1e-9,
"recovery begins at $100 a day"
@@ -466,9 +457,9 @@ export class CombatTest extends TestCase {
for (let day = 0; day < Math.ceil(deficit / BATTLE.recoveryPerDay) + 1; day++) {
state.totalHours += DAY;
- state._tickGdpRecovery();
+ state._tickProductionRecovery();
}
- this.assertFalse(state.tileBattleGdpDeficit.has(k), "the wound heals completely");
+ this.assertFalse(state.tileBattleProductionDeficit.has(k), "the wound heals completely");
this.assertFalse(state.tileBattleRecoveryStart.has(k), "and the clock is cleared");
}
@@ -477,9 +468,9 @@ export class CombatTest extends TestCase {
const { b } = joinBattleOn(state, ownedLandPair(state));
const k = key(b.x, b.y);
const before = state.getTileGdpPerCapita(b);
- this.assertFalse(state.tileBattleGdpDeficit.has(k), "the tile is untouched at first");
+ this.assertFalse(state.tileBattleProductionDeficit.has(k), "the tile is untouched at first");
for (let hour = 0; hour < DAY; hour++) state.tickHour();
- this.assertTrue(state.tileBattleGdpDeficit.has(k), "the daily tick records the scar");
+ this.assertTrue(state.tileBattleProductionDeficit.has(k), "the daily tick records the scar");
this.assertLess(state.getTileGdpPerCapita(b), before, "and the tile is worth less");
this.assertEqual(state.tileBattleRecoveryStart.get(k), DAY + BATTLE.recoveryDelayDays * DAY);
}
@@ -645,10 +636,12 @@ function ownedLandPair(state) {
for (const cell of state.landCells) {
if (state.civAt(cell) < 0) continue;
if (state.unitAt(cell) || state.cityAt(cell)) continue;
+ if (state.tileImprovements.has(key(cell.x, cell.y))) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.civAt(neighbour) < 0) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
+ if (state.tileImprovements.has(key(neighbour.x, neighbour.y))) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
diff --git a/tests/data_test.js b/tests/data_test.js
index 6ea8e82..49e45ce 100644
--- a/tests/data_test.js
+++ b/tests/data_test.js
@@ -17,6 +17,7 @@ import {
PILLAGE,
pillageSteps,
POLICIES,
+ RESOURCE_BUILDINGS,
} from "../shared/data.js";
export class DataTest extends TestCase {
@@ -27,6 +28,13 @@ export class DataTest extends TestCase {
this.assertEqual(technologyById("scientific_method"), TECHNOLOGIES[0]);
}
+ test_resource_building_output_is_scaled_up() {
+ // One work stands for a whole industry: the authored output is multiplied
+ // tenfold so fewer of them stand on the map.
+ const mill = RESOURCE_BUILDINGS.find((b) => b.id === "steel_mill");
+ this.assertApprox(mill.outputPerDay, 20_000, 1e-6);
+ }
+
test_unknown_lookups_return_null() {
this.assertNull(protoUnitById("nope"));
this.assertNull(buildingById("nope"));
@@ -57,14 +65,14 @@ export class DataTest extends TestCase {
const one = pillageSteps(100000, 1);
this.assertApprox(one.steps[0].removed, 10000, 1e-6);
this.assertApprox(one.steps[0].heal, 10, 1e-9);
- this.assertApprox(one.gdpMultiplier, PILLAGE.gdpPenalty, 1e-9);
+ this.assertApprox(one.productionMultiplier, PILLAGE.productionPenalty, 1e-9);
const two = pillageSteps(100000, 2);
this.assertApprox(two.steps[0].removed, 10000, 1e-6);
this.assertApprox(two.steps[1].removed, 9000, 1e-6);
this.assertApprox(two.steps[1].heal, 9, 1e-9);
this.assertApprox(two.steps[1].remaining, 81000, 1e-6);
- this.assertApprox(two.gdpMultiplier, 0.49, 1e-9);
+ this.assertApprox(two.productionMultiplier, 0.49, 1e-9);
}
test_trainable_units_exist_in_the_catalogue() {
@@ -133,7 +141,7 @@ export class DataTest extends TestCase {
shelters: "civilian_shelter",
aa_building: "air_defense",
shipyard: "parallel_building",
- port: "port_gdp",
+ port: "port_production",
airport: "air_immigration",
};
for (const [id, mechanic] of Object.entries(expected)) {
@@ -196,7 +204,7 @@ export class DataTest extends TestCase {
this.assertEqual(tundra.terrainClass, "Land", "tundra is land");
this.assertEqual(tundra.defenseBonus, 0, "tundra stays flat");
this.assertApprox(tundra.populationMultiplier, 0.05, 1e-9);
- this.assertApprox(tundra.gdpMultiplier, 0.2, 1e-9);
+ this.assertApprox(tundra.productionMultiplier, 0.2, 1e-9);
this.assertApprox(tundra.movementCostMultiplier, 2.0, 1e-9);
}
}
diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js
index bc31cee..9e3c0b1 100644
--- a/tests/framework/helpers.js
+++ b/tests/framework/helpers.js
@@ -71,6 +71,7 @@ export function mapLayers($) {
highlight: $("#layer-highlight"),
paths: $("#layer-paths"),
targets: $("#layer-targets"),
+ resources: $("#layer-resources"),
entities: $("#layer-entities"),
labels: $("#layer-labels"),
};
@@ -109,6 +110,19 @@ export function unitsOf(state, civ) {
return state.units.filter((unit) => unit.civ === civ).length;
}
+// Removes the port every naval city opens with, so a GDP-sensitive test starts
+// from the same terrain-only economy it would have had before ports were seeded.
+export function stripPorts(state, civ = null) {
+ const index = state.protoBuildings.findIndex((building) => building.id === "port");
+ if (index < 0) return;
+ for (const city of state.cities) {
+ if (civ !== null && city.civ !== civ) continue;
+ if (city.buildings[index]) delete city.buildings[index];
+ }
+ state._gdpPerCapitaCache.clear();
+ state._touchModifiers();
+}
+
export function snapshotUnit(state, unitId, viewer = 0) {
return (state.snapshot(viewer).units || []).find((u) => u.id === unitId) || null;
}
@@ -120,7 +134,7 @@ export function gdpBuilding(overrides = {}) {
baseCost: 100,
costAlpha: 2.0,
refundFraction: 0.5,
- effects: [{ stat: "gdp", baseValue: 0.5, exponent: 1.0 }],
+ effects: [{ stat: "production", baseValue: 0.5, exponent: 1.0 }],
...overrides,
};
}
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index b844d81..5118387 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -1,10 +1,12 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
-import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY } from "../shared/data.js";
+import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY, TRANSPORT_IMPROVEMENTS } from "../shared/data.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
import { hoursInNextMonth } from "../shared/game_clock.js";
import { compact } from "../shared/text_format.js";
+import { tileImprovementResourceCost } from "../shared/resources.js";
+import { quotedBuildCost, marketPricesFrom } from "../client/js/modals/format.js";
import { parseKey } from "../shared/hex.js";
import { smallState, adjacentLand, grantBuilding } from "./framework/helpers.js";
@@ -51,12 +53,12 @@ export class GameScreenTest extends TestCase {
const territory = screen.map.territory;
const regions = screen.map.regions;
const ethnicity = screen._tileEthnicity;
- const baseline = screen._gdpBaseline;
+ const baseline = screen._productionBaseline;
// A second snapshot with no state change carries the same versions, so
// none of the big per-tile maps should be rebuilt.
screen.onState(state.snapshot(0));
this.assert(population === screen._population, "population map is reused");
- this.assert(baseline === screen._gdpBaseline, "the growth baseline is reused");
+ this.assert(baseline === screen._productionBaseline, "the growth baseline is reused");
this.assert(territory === screen.map.territory, "territory map is reused");
this.assert(regions === screen.map.regions, "region map is reused");
this.assert(ethnicity === screen._tileEthnicity, "ethnicity map is reused");
@@ -77,7 +79,7 @@ export class GameScreenTest extends TestCase {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
grantBuilding(state, capital, "barracks");
screen.onState(state.snapshot(0));
- this.assertEqual(env.$("#civ-name").text(), "France");
+ this.assertEqual(env.$("#civ-name").text(), state.civilisations[0].name);
this.assertEqual(env.$("#player-name").text(), "Carol");
this.assertTrue(env.$("#stat-pop").text() !== "0");
this.assertTrue(env.$("#stat-date").text().includes("2000"));
@@ -616,37 +618,21 @@ export class GameScreenTest extends TestCase {
screen.map.onTileRequested(capital.coords);
this.assertFalse(env.$("#tile-info").hasClass("hidden"));
this.assertTrue(env.$("#tile-coords").text().includes("Tile"));
- this.assertTrue(env.$("#tile-gdp").text().includes("GDP/capita"));
+ // The panel leads with what the tile produces, and GDP is only shown for
+ // its region.
this.assertTrue(
- env.$("#tile-gdp").text().includes("Capital"),
- "the tile panel explains the capital GDP bonus"
+ env.$("#tile-gdp").text().includes("Region GDP/capita"),
+ "GDP is shown at the region level"
+ );
+ this.assertGreater(
+ env.$("#tile-production .tile-prod-row").length,
+ 0,
+ "the tile panel lists the resources it produces"
+ );
+ this.assertTrue(
+ env.$("#tile-production").text().includes("Food"),
+ "the capital's people grow food"
);
-
- screen._renderTileGdp({
- base: 10000,
- factors: [
- { label: "Terrain", multiplier: 1 },
- { label: "City", multiplier: 2 },
- { label: "Battle damage", amount: 0 },
- ],
- total: 20000,
- });
- const gdpText = env.$("#tile-gdp").text();
- this.assertFalse(gdpText.includes("Terrain"), "a no-op multiplier is hidden");
- this.assertFalse(gdpText.includes("Battle damage"), "a zero modifier is hidden");
- this.assertTrue(gdpText.includes("City"), "a real modifier is still shown");
-
- screen._renderTileGdp({
- base: 10000,
- factors: [
- { label: "Natural growth", amount: 150 },
- { label: "Battle damage", amount: -50 },
- ],
- total: 10100,
- });
- const amountText = env.$("#tile-gdp").text();
- this.assertTrue(amountText.includes("Natural growth +€150"), "a gain shows a plus");
- this.assertTrue(amountText.includes("Battle damage −€50"), "a loss keeps its minus");
const unit = state.units.find((u) => u.civ === 0);
screen.map.onUnitSelected(unit.id);
@@ -1015,7 +1001,7 @@ export class GameScreenTest extends TestCase {
await screen.map.onMoveOrdered([unit.id], goal);
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens");
this.assertEmpty(network.orders, "nothing is sent before confirming");
- this.assertTrue(env.$("#confirm-message").text().includes("Britain"));
+ this.assertTrue(env.$("#confirm-message").text().includes(state.civilisations[1].name));
env.$("#confirm-ok").click();
this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [900, 900] });
@@ -1079,7 +1065,7 @@ export class GameScreenTest extends TestCase {
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
- const row = env.$("#diplomacy-list tbody tr").first();
+ const row = env.$("#diplomacy-list tbody tr:not(.local-nation)").first();
this.assertTrue(row.find(".name").text().length > 0);
row.find("button").click();
this.assertEqual(network.orders[0].type, "declare_war");
@@ -1124,7 +1110,7 @@ export class GameScreenTest extends TestCase {
screen.onState(state.snapshot(0));
env.$("#news-button").click();
this.assertFalse(env.$("#modal-news").hasClass("hidden"));
- this.assertTrue(env.$("#news-list").text().includes("declared war on Britain"));
+ this.assertTrue(env.$("#news-list").text().includes(`declared war on ${state.civilisations[1].name}`));
screen.leave();
} finally {
teardownDom(env);
@@ -1144,7 +1130,7 @@ export class GameScreenTest extends TestCase {
state.requestDeclareWar(0, 1);
screen.onState(state.snapshot(0));
this.assertSize(env.$("#game-feed .feed-line"), 1);
- this.assertTrue(env.$("#game-feed .feed-line").text().includes("declared war on Britain"));
+ this.assertTrue(env.$("#game-feed .feed-line").text().includes(`declared war on ${state.civilisations[1].name}`));
screen.leave();
} finally {
teardownDom(env);
@@ -1530,7 +1516,7 @@ export class GameScreenTest extends TestCase {
const coords = state.landCells.find((cell) => {
const k = `${cell.x},${cell.y}`;
return state.civAt(cell) === 0 && !state.cityAt(cell) &&
- !state.roads.has(k) && !state.railways.has(k);
+ !state.roads.has(k) && !state.railways.has(k) && !state.tileImprovements.has(k);
});
this.assertNotNull(coords, "the player owns a bare land tile");
screen._showTile(coords);
@@ -1538,12 +1524,32 @@ export class GameScreenTest extends TestCase {
this.assertEqual(options.length, 2, "road and railway are listed");
this.assertFalse(options.eq(0).prop("disabled"), "the road option is clickable");
this.assertFalse(options.eq(1).prop("disabled"), "the railway option is clickable");
- // Each is a square icon with its name in the tooltip and cost beneath.
+ // Each is a square icon with its name in the tooltip and its resource and
+ // money cost beneath.
this.assertTrue(
options.eq(0).find("img.improvement-icon").attr("src").endsWith("icon_road.svg"),
"the road button shows its icon"
);
- this.assertEqual(options.eq(0).find(".improvement-cost").text(), "€50M", "cost under the icon");
+ const costText = options.eq(0).find(".improvement-cost").text();
+ this.assertTrue(costText.includes("5000 t"), `the steel cost is shown: ${costText}`);
+ this.assertTrue(costText.includes("100 GWh"), `the energy cost is shown: ${costText}`);
+ // The money figure is the build cost plus the material bill at today's
+ // market prices, not the raw build cost.
+ const roadProto = TRANSPORT_IMPROVEMENTS.find((i) => i.id === "road");
+ const expectedCost = quotedBuildCost(
+ roadProto.buildCost,
+ tileImprovementResourceCost(roadProto),
+ marketPricesFrom(screen.snapshot)
+ );
+ this.assertTrue(
+ costText.includes(`£${compact(expectedCost)}`),
+ `the market price is shown: ${costText}`
+ );
+ this.assertEqual(
+ options.eq(0).find(".improvement-cost img.cost-icon").length,
+ 2,
+ "steel and energy carry resource icons"
+ );
this.assertTrue(
options.eq(0).attr("title").startsWith("Road"),
"the name is the tooltip"
@@ -1556,10 +1562,16 @@ export class GameScreenTest extends TestCase {
this.assertEqual(order.improvement, "road");
// A tile that already carries the improvement greys it out, keeps the
- // upgrade and offers a remove control.
+ // upgrade and offers a remove control. The seeded starter industry also
+ // puts works on network tiles, so clear any off this one first.
const road = Array.from(state.roads).map((k) => k.split(",").map(Number))[0];
+ const roadKey = `${road[0]},${road[1]}`;
+ state.tileImprovements.delete(roadKey);
+ state.tileImprovementOwner.delete(roadKey);
+ state.tileImprovementHp.delete(roadKey);
+ screen.onState(state.snapshot(0));
screen._showTile({ x: road[0], y: road[1] });
- const again = env.$("#tile-improvement-actions .improvement-btn").not(".remove");
+ const again = env.$("#tile-improvement-actions .transport-btn").not(".remove");
this.assertTrue(again.eq(0).prop("disabled"), "the built road is greyed out");
this.assertFalse(again.eq(1).prop("disabled"), "the railway upgrade stays available");
const $remove = env.$("#tile-improvement-actions .improvement-btn.remove");
@@ -1575,6 +1587,71 @@ export class GameScreenTest extends TestCase {
}
}
+ async test_improvement_buttons_are_grouped_by_category() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ state.budgets.set(0, 1.0e15);
+ screen.onState(state.snapshot(0));
+ const coords = state.landCells.find((cell) => {
+ const k = `${cell.x},${cell.y}`;
+ return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
+ });
+ this.assertNotNull(coords, "the player owns a bare land tile");
+ screen._showTile(coords);
+ const labels = env.$("#tile-improvement-actions .improvement-category")
+ .map((_, el) => env.$(el).text())
+ .get();
+ this.assertEqual(
+ labels.join(","),
+ "Military,Transportation,Economic",
+ "the categories are labelled in order"
+ );
+ const firstClasses = env.$("#tile-improvement-actions .improvement-row")
+ .map((_, el) => env.$(el).find(".improvement-btn").first().attr("class"))
+ .get();
+ this.assertTrue(firstClasses[0].includes("tile-btn"), "military works come first");
+ this.assertTrue(firstClasses[1].includes("transport-btn"), "transport comes second");
+ this.assertTrue(firstClasses[2].includes("resource-btn"), "economic producers come last");
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_improvement_cost_tracks_market_prices() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = smallState();
+ state.budgets.set(0, 1.0e15);
+ const coords = state.landCells.find((cell) => {
+ const k = `${cell.x},${cell.y}`;
+ return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
+ });
+ this.assertNotNull(coords, "the player owns a bare land tile");
+ screen.onState(state.snapshot(0));
+ screen._showTile(coords);
+ const before = env.$("#tile-improvement-actions .transport-btn").eq(0)
+ .find(".improvement-cost").text();
+ // A jump in the world steel price raises the quoted price, without
+ // changing the amount of steel shown.
+ state.setResourcePrice("steel", state.getResourcePrice("steel") * 3);
+ screen.onState(state.snapshot(0));
+ screen._showTile(coords);
+ const after = env.$("#tile-improvement-actions .transport-btn").eq(0)
+ .find(".improvement-cost").text();
+ this.assertNotEqual(before, after, `the quote moved: ${before} -> ${after}`);
+ this.assertTrue(after.includes("5000 t"), "the steel amount is unchanged");
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_tile_panel_shows_improvement_build_progress() {
const env = await setupDom();
try {
@@ -1584,7 +1661,8 @@ export class GameScreenTest extends TestCase {
state.budgets.set(0, 1.0e15);
const coords = state.landCells.find((cell) => {
const k = `${cell.x},${cell.y}`;
- return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.unitAt(cell);
+ return state.civAt(cell) === 0 && !state.cityAt(cell) &&
+ !state.unitAt(cell) && !state.tileImprovements.has(k);
});
this.assertNotNull(coords, "the player owns a bare land tile");
this.assertTrue(
@@ -1765,7 +1843,7 @@ export class GameScreenTest extends TestCase {
const sign = expected < 0 ? "−" : "+";
this.assertEqual(
env.$("#stat-budget").closest(".stat").attr("data-tooltip"),
- `${sign}€${compact(Math.abs(expected))}/mo`
+ `${sign}£${compact(Math.abs(expected))}/mo`
);
// Popularity speaks through the same bubble, not a native title.
const $approval = env.$("#stat-approval").closest(".stat");
@@ -1777,33 +1855,23 @@ export class GameScreenTest extends TestCase {
}
}
- async test_the_tile_gdp_breakdown_includes_natural_growth() {
+ async test_the_tile_panel_lists_produced_resources() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = smallState();
- state.totalHours = 10 * HOURS_PER_DAY;
screen.onState(state.snapshot(0));
- const k = state.tileGdpBaseline.keys().next().value;
- const coords = parseKey(k);
- const shipped = Math.round(state.tileGdpBaseline.get(k));
- const expected = shipped * ECONOMY.naturalGdpGrowthPerDay * 10;
- this.assertApprox(
- screen._naturalTileGdpGrowth(k),
- expected,
- 1e-6,
- "the browser mirrors the server's growth"
+ const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
+ screen._showTile({ x: capital.coords.x, y: capital.coords.y });
+ const $food = env.$("#tile-production .tile-prod-row").filter((_, el) =>
+ env.$(el).text().includes("Food")
);
- const breakdown = screen._tileGdpBreakdown(
- coords,
- 0,
- state.cityAt(coords),
- screen.map.getTile(coords)
- );
- const factor = breakdown.factors.find((f) => f.label === "Natural growth");
- this.assertNotNull(factor, "the breakdown names the growth");
- this.assertApprox(factor.amount, expected, 1e-6, "and adds it to the total");
+ this.assertEqual($food.length, 1, "the capital grows food");
+ this.assertTrue($food.text().includes("/day"), "its output is per day");
+ // The modifiers that shape the output ride along in the row's tooltip.
+ this.assertNotNull($food.attr("title"), "the row explains its modifiers");
+ this.assertTrue($food.attr("title").includes("Capital"), `cites the capital bonus: ${$food.attr("title")}`);
screen.leave();
} finally {
teardownDom(env);
diff --git a/tests/game_server_test.js b/tests/game_server_test.js
index 93a1e44..8f2c11c 100644
--- a/tests/game_server_test.js
+++ b/tests/game_server_test.js
@@ -416,7 +416,7 @@ export class GameServerOrdersTest extends TestCase {
const choose = network.sent.find((m) => m.peerId === 2 && m.message.t === "choose_civ");
this.assertNotNull(choose, "the joiner is offered a choice");
this.assertEqual(choose.message.civs.map((c) => c.index), [1]);
- this.assertEqual(choose.message.civs[0].id, "britain");
+ this.assertEqual(choose.message.civs[0].id, server.state.civilisations[1].id);
this.assertEqual(server.peerCiv.get(2), -1, "no nation until the player picks");
}
@@ -585,7 +585,7 @@ export class GameServerLobbyTest extends TestCase {
this.assertEqual(game.seed, SEED);
this.assertEqual(game.id, SEED);
this.assertSize(game.civs, 2);
- this.assertEqual(game.civs[0], "France");
+ this.assertEqual(game.civs.slice().sort(), ["Britain", "France"]);
this.assertEqual(game.players, 0);
this.assertTrue(game.date.includes("2000"));
}
diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js
index 33ae7a4..aa641b1 100644
--- a/tests/game_state_buildings_test.js
+++ b/tests/game_state_buildings_test.js
@@ -1,7 +1,9 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
-import { ECONOMY, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH } from "../shared/data.js";
+import { ECONOMY, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, RESOURCE_RULES } from "../shared/data.js";
+import { tileFoodOutput } from "../shared/resources.js";
+import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
const BIG_BUDGET = 1.0e15;
@@ -116,17 +118,54 @@ export class GameStateBuildingsTest extends TestCase {
return tile && tile.terrainClass === "Sea";
}).length;
this.assertGreater(sea, 0, "the city touches the sea");
+ const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
+ // A naval city already starts with one port level.
+ this.assertEqual(city.buildings[portIndex], 1, "the seeded port is there");
+ city.buildings[portIndex] = 0;
+ state._gdpPerCapitaCache.clear();
const sample = state.regionTiles(city).find((coords) => !state.cityAt(coords)) || state.regionTiles(city)[0];
+ // Isolate the port's GDP bonus: the port also lifts food output, which
+ // feeds back into the production-based base. That is tested separately.
+ const foodMultiplier = state._regionFoodMultiplier;
+ state._regionFoodMultiplier = () => 1;
const before = state.getTileGdpPerCapita(sample);
- grantBuilding(state, city, "port", 2);
+ city.buildings[portIndex] = 2;
state._gdpPerCapitaCache.clear();
const after = state.getTileGdpPerCapita(sample);
+ state._regionFoodMultiplier = foodMultiplier;
// +1000 per port level for each neighbouring sea tile, on every region tile.
this.assertApprox(after - before, 1000 * sea * 2, 1e-6, "the port lifts the whole region");
}
+ test_port_lifts_its_regions_food_output() {
+ const state = smallState();
+ const city = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
+ this.assertNotNull(city, "the nation has a coastal city");
+ const sea = state.topology
+ .neighbours(city.coords.x, city.coords.y)
+ .filter((n) => {
+ const tile = state.tiles[key(n.x, n.y)];
+ return tile && tile.terrainClass === "Sea";
+ }).length;
+ this.assertGreater(sea, 0, "the city touches the sea");
+ const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
+ city.buildings[portIndex] = 0;
+ const before = state._regionProductionValue(city);
+ city.buildings[portIndex] = 1;
+ const after = state._regionProductionValue(city);
+ // The port adds 5% of the region's food output for each adjacent sea tile.
+ const foodValue = state.regionTiles(city).reduce((sum, coords) => {
+ const k = key(coords.x, coords.y);
+ const tile = state.tiles[k];
+ const population = state.tilePopulation.get(k) || 0;
+ if (population <= 0) return sum;
+ return sum + tileFoodOutput(tile) * state.getResourcePrice("food");
+ }, 0) * DAYS_PER_YEAR;
+ this.assertApprox(after - before, foodValue * 0.05 * sea, foodValue * 1e-6, "the port grows more food");
+ }
+
test_request_build_charges_budget_and_raises_the_level() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
@@ -176,11 +215,13 @@ export class GameStateBuildingsTest extends TestCase {
this.assertEqual(state.getCityBuildingLevel(city, index), 2);
}
- test_request_build_rejects_when_unaffordable() {
+ test_request_build_goes_ahead_on_an_empty_treasury() {
const state = smallState();
state.budgets.set(0, 0);
const index = this.addBuilding(state, gdpBuilding());
- this.assertFalse(state.requestBuild(cityOf(state, 0).id, index));
+ // The government can always pay, running an overdraft with the central bank.
+ this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
+ this.assertLess(state.getBudget(0), 0, "the treasury went into debt");
}
test_request_build_has_no_level_cap() {
@@ -218,17 +259,19 @@ export class GameStateBuildingsTest extends TestCase {
this.assertFalse(state.requestDemolish(city.id, index), "nothing left to demolish");
}
- test_building_upkeep_is_added_to_player_upkeep() {
+ test_building_upkeep_is_added_to_material_upkeep() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
const index = this.addBuilding(
state,
gdpBuilding({ baseCost: 10_000, costAlpha: 1.0 })
);
- const before = state.getPlayerUpkeep(0);
+ const before = state.getBuildingUpkeep(0);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
this.advanceUntilIdle(state);
- this.assertApprox(state.getPlayerUpkeep(0), before + 100);
+ // 5e-7 t of steel and 1e-3 kWh of energy per euro of the level's cost.
+ this.assertApprox(state.getBuildingUpkeep(0).steel, before.steel + 0.005, 1e-9);
+ this.assertApprox(state.getBuildingUpkeep(0).energy, before.energy + 10, 1e-9);
}
test_research_buildings_accumulate_points_each_hour() {
@@ -264,16 +307,17 @@ export class GameStateBuildingsTest extends TestCase {
this.assertHas(breakdown, "buildingUpkeep");
this.assertHas(breakdown, "unitUpkeep");
this.assertHas(breakdown, "transportUpkeep");
- this.assertApprox(breakdown.buildingUpkeep.total, 100);
- this.assertApprox(
- breakdown.unitUpkeep.total,
- state.getPlayerUpkeep(0) -
- breakdown.buildingUpkeep.total -
- breakdown.campaignUpkeep.total -
- breakdown.transportUpkeep.total
- );
- const expected = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
- this.assertApprox(breakdown.income.total, expected);
+ // Upkeep lines are the market value of the materials the nation must buy.
+ const expected = state._upkeepCost(state.getBuildingUpkeep(0));
+ this.assertApprox(breakdown.buildingUpkeep.total, expected, 1e-6);
+ // The only direct money upkeep left is propaganda.
+ this.assertApprox(state.getPlayerUpkeep(0), breakdown.campaignUpkeep.total, 1e-9);
+ const expected_net = breakdown.income.total - breakdown.buildingUpkeep.total -
+ breakdown.unitUpkeep.total - breakdown.campaignUpkeep.total -
+ breakdown.transportUpkeep.total - breakdown.tileUpkeep.total;
+ this.assertApprox(breakdown.net, expected_net, 1e-6);
+ const income = state.getPlayerGdp(0) * ECONOMY.budgetGdpRate * (1 + breakdown.income.modifier);
+ this.assertApprox(breakdown.income.total, income, 1e-6);
}
test_budget_breakdown_names_income_sources() {
diff --git a/tests/game_state_government_test.js b/tests/game_state_government_test.js
index a711bdf..6da03d8 100644
--- a/tests/game_state_government_test.js
+++ b/tests/game_state_government_test.js
@@ -1,5 +1,5 @@
import { TestCase } from "./framework/test_case.js";
-import { smallState } from "./framework/helpers.js";
+import { smallState, stripPorts } from "./framework/helpers.js";
import { GOVERNMENTS } from "../shared/data.js";
const BIG_BUDGET = 1.0e15;
@@ -30,14 +30,19 @@ export class GameStateGovernmentTest extends TestCase {
this.assertFalse(state.requestSetGovernment(999, 1), "unknown civilisation");
}
- test_request_set_government_rejects_when_unaffordable() {
+ test_request_set_government_goes_ahead_on_an_empty_treasury() {
const state = smallState();
state.budgets.set(0, 0);
- this.assertFalse(state.requestSetGovernment(0, 1));
+ // The government can always pay, borrowing from the central bank.
+ this.assertTrue(state.requestSetGovernment(0, 1));
+ this.assertLess(state.getBudget(0), 0, "the adoption cost became debt");
}
test_government_effects_are_folded_into_gdp() {
const state = smallState();
+ // Ports add a flat GDP that is not scaled by the government, so strip the
+ // seeded ones to keep the government multiplier the only variable.
+ stripPorts(state, 0);
state.budgets.set(0, BIG_BUDGET);
const monarchy = state.governments.findIndex((g) => g.id === "monarchy");
const republicGdp = state.getPlayerGdp(0);
diff --git a/tests/game_state_technology_test.js b/tests/game_state_technology_test.js
index ee6329d..880a95e 100644
--- a/tests/game_state_technology_test.js
+++ b/tests/game_state_technology_test.js
@@ -1,5 +1,5 @@
import { TestCase } from "./framework/test_case.js";
-import { smallState } from "./framework/helpers.js";
+import { smallState, stripPorts } from "./framework/helpers.js";
import { TECHNOLOGIES, EFFECT_INFANTRY_ATTACK, EFFECT_CONSTRUCTION_SPEED } from "../shared/data.js";
import { NEWS_TECHNOLOGY } from "../shared/data/relations.js";
@@ -104,6 +104,7 @@ export class GameStateTechnologyTest extends TestCase {
test_technology_effects_are_folded_into_gdp() {
const state = smallState();
+ stripPorts(state, 0);
const before = state.getPlayerGdp(0);
this.complete(state, "scientific_method");
this.assertApprox(state.getPlayerGdp(0), (before * 1.07) / 1.05, 0.01);
@@ -188,4 +189,11 @@ export class GameStateTechnologyTest extends TestCase {
);
}
}
+
+ test_research_costs_a_hundred_times_the_catalogue_price() {
+ const state = smallState();
+ const index = this.index(state, "scientific_method");
+ const proto = TECHNOLOGIES[index];
+ this.assertEqual(state.getTechnologyCost(0, index), proto.cost * 100);
+ }
}
diff --git a/tests/game_state_test.js b/tests/game_state_test.js
index accfb75..3bad688 100644
--- a/tests/game_state_test.js
+++ b/tests/game_state_test.js
@@ -119,7 +119,8 @@ export class GameStateTest extends TestCase {
const state = smallState();
this.assertGreater(state.getPlayerPopulation(0), 0);
this.assertGreater(state.getPlayerGdp(0), 0);
- this.assertGreater(state.getPlayerUpkeep(0), 0);
+ // Upkeep is now materials, not money: a starting army wears out steel.
+ this.assertGreater(state.getCivUpkeepResources(0).steel, 0);
this.assertApprox(
state.getBudget(0),
state.getPlayerGdp(0) * ECONOMY.startingBudgetFraction,
@@ -128,7 +129,7 @@ export class GameStateTest extends TestCase {
);
}
- test_civilisations_start_with_equal_cities_population_and_gdp_per_capita() {
+ test_civilisations_start_with_equal_cities_and_population() {
const state = smallState(["france", "britain", "slovenia"]);
const count = state.civilisations.length;
const cities = [];
@@ -142,8 +143,14 @@ export class GameStateTest extends TestCase {
}
this.assertEqual(cities, [2, 2, 2], "every civ starts with the same number of cities");
this.assertEqual(populations, [populations[0], populations[0], populations[0]], "every civ starts with the same population");
+ // GDP is the market value of what the land and its seeded works produce, so
+ // terrain makes it differ a little between nations; the balance keeps them
+ // within a narrow band rather than exactly equal.
for (const perCapita of perCapitas) {
- this.assertApprox(perCapita, perCapitas[0], 1e-6, "every civ starts with the same GDP per capita");
+ this.assertTrue(
+ Math.abs(perCapita - perCapitas[0]) <= perCapitas[0] * 0.15,
+ "every civ starts with a comparable GDP per capita"
+ );
}
}
diff --git a/tests/growth_test.js b/tests/growth_test.js
index 5b12613..1c4615b 100644
--- a/tests/growth_test.js
+++ b/tests/growth_test.js
@@ -4,36 +4,42 @@ import { ECONOMY } from "../shared/data.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
import { parseKey } from "../shared/hex.js";
-// Natural GDP-per-capita growth: a constant daily amount measured from each
+// Natural production growth: a constant daily amount measured from each
// tile's opening figure, so the relative growth rate tapers over the years.
export class GdpGrowthTest extends TestCase {
firstBaseline(state) {
- const k = state.tileGdpBaseline.keys().next().value;
- return { k, baseline: state.tileGdpBaseline.get(k), coords: parseKey(k) };
+ const k = state.tileProductionBaseline.keys().next().value;
+ return { k, baseline: state.tileProductionBaseline.get(k), coords: parseKey(k) };
}
test_a_day_adds_a_constant_share_of_the_opening_figure() {
const state = smallState();
+ // Measure natural growth alone; migration would move people between regions
+ // and shift the per-capita on its own.
+ state._migrateForIncome = () => {};
const { k, baseline, coords } = this.firstBaseline(state);
- this.assertTrue(baseline > 0, "the tile has an opening GDP per capita");
+ this.assertTrue(baseline > 0, "the tile has an opening production per head");
const opening = state.getTileGdpPerCapita(coords);
this.assertApprox(opening, baseline, 1e-9, "day zero is the baseline");
for (let i = 0; i < HOURS_PER_DAY; i++) state.tickHour();
const dayOne = state.getTileGdpPerCapita(coords);
- const step = baseline * ECONOMY.naturalGdpGrowthPerDay;
- this.assertApprox(dayOne - opening, step, 1e-6, "one day adds one step");
- // Thirty more days add exactly thirty more steps: the amount is constant.
+ const step = baseline * ECONOMY.naturalProductionGrowthPerDay;
+ this.assertTrue(dayOne > opening, "the tile has grown after a day");
+ // The total also tracks market prices, which drift as production meets
+ // demand; the natural-growth term itself is what adds exactly one step.
+ this.assertApprox(state._naturalTileProductionGrowth(k), step, 1e-6, "one day adds one step");
+ // Thirty more days add exactly thirty more steps: the natural-growth term is
+ // a constant. The tile's total also tracks production and market prices,
+ // which drift on their own, so the term itself is what stays linear.
state.totalHours = 31 * HOURS_PER_DAY;
- state._gdpPerCapitaCache.clear();
- const dayThirtyOne = state.getTileGdpPerCapita(coords);
- this.assertApprox(dayThirtyOne - dayOne, 30 * step, 1e-6, "the daily amount never grows");
- this.assertEqual(k, state.tileGdpBaseline.keys().next().value, "the baseline never moves");
+ this.assertApprox(state._naturalTileProductionGrowth(k), 31 * step, 1e-6, "the daily amount never grows");
+ this.assertEqual(k, state.tileProductionBaseline.keys().next().value, "the baseline never moves");
}
test_the_relative_growth_rate_falls_as_the_tile_grows() {
const state = smallState();
const { baseline, coords } = this.firstBaseline(state);
- const step = baseline * ECONOMY.naturalGdpGrowthPerDay;
+ const step = baseline * ECONOMY.naturalProductionGrowthPerDay;
const earlyRate = step / baseline;
state.totalHours = 366 * HOURS_PER_DAY;
state._gdpPerCapitaCache.clear();
@@ -47,6 +53,8 @@ export class GdpGrowthTest extends TestCase {
test_growth_is_measured_from_january_2000_in_the_first_year() {
const state = smallState();
+ // Hold migration still so the day's change is natural growth alone.
+ state._migrateForIncome = () => {};
const growth = state.viewerStats(0).growth;
this.assertNotNull(growth, "the viewer carries growth figures");
this.assertTrue(growth.sinceStart, "the first year measures from January 2000");
@@ -85,12 +93,12 @@ export class GdpGrowthTest extends TestCase {
const state = smallState();
const first = state.snapshot(0);
this.assertSize(
- first.gdpBaseline,
- state.tileGdpBaseline.size,
+ first.productionBaseline,
+ state.tileProductionBaseline.size,
"every baseline tile travels"
);
this.assertNotNull(first.viewerStats.growth, "the viewer's growth travels too");
// The version never moves, so the delta wire drops the baseline afterwards.
- this.assertEqual(first.versions.gdpBaseline, 0, "the baseline version is static");
+ this.assertEqual(first.versions.productionBaseline, 0, "the baseline version is static");
}
}
diff --git a/tests/improvements_test.js b/tests/improvements_test.js
index 55c0a44..9c3d6f6 100644
--- a/tests/improvements_test.js
+++ b/tests/improvements_test.js
@@ -1,7 +1,9 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
-import { TRANSPORT_BY_ID } from "../shared/data/improvements.js";
+import { TRANSPORT_BY_ID, TILE_IMPROVEMENT_BY_ID } from "../shared/data/improvements.js";
+import { RESOURCE_RULES } from "../shared/data/resources.js";
+import { tileImprovementResourceCost } from "../shared/resources.js";
const BIG_BUDGET = 10_000_000_000;
@@ -14,9 +16,14 @@ function bareOwnedLand(state, civ = 0) {
}
export class ImprovementsTest extends TestCase {
- test_building_a_road_costs_money_and_lays_the_tile() {
+ test_building_a_road_costs_money_and_resources_and_lays_the_tile() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
+ // Stock enough steel that only the electricity is bought on the market, so
+ // the money bill is the build cost plus the construction energy.
+ for (const city of state.cities) {
+ if (city.civ === 0) state.getCityResourceStock(city).steel = 1e9;
+ }
const coords = bareOwnedLand(state);
this.assertNotNull(coords, "the map has a bare owned land tile");
const k = key(coords.x, coords.y);
@@ -24,7 +31,14 @@ export class ImprovementsTest extends TestCase {
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
this.assertTrue(state.roads.has(k), "the road is on the tile");
this.assertFalse(state.railways.has(k), "it is not a railway");
- this.assertApprox(before - state.getBudget(0), TRANSPORT_BY_ID.road.buildCost);
+ const materials = tileImprovementResourceCost(TRANSPORT_BY_ID.road);
+ const energyCost = materials.energy * state.getResourcePrice("energy");
+ this.assertApprox(
+ before - state.getBudget(0),
+ TRANSPORT_BY_ID.road.buildCost + energyCost,
+ 1e-3,
+ "money plus the construction electricity"
+ );
}
test_a_railway_replaces_a_road_and_moves_twice_as_fast() {
@@ -95,12 +109,14 @@ export class ImprovementsTest extends TestCase {
this.assertApprox(state.getBudget(0), after, 1e-9, "nothing was charged");
}
- test_poor_nations_cannot_build() {
+ test_a_road_can_be_built_on_an_empty_treasury() {
const state = smallState();
state.budgets.set(0, 0);
const coords = bareOwnedLand(state);
- this.assertFalse(state.requestBuildImprovement(0, coords, "road"));
- this.assertFalse(state.roads.has(key(coords.x, coords.y)));
+ // The government can always pay, so the road goes ahead on borrowed money.
+ this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
+ this.assertTrue(state.roads.has(key(coords.x, coords.y)));
+ this.assertLess(state.getBudget(0), 0, "the cost became central-bank debt");
}
test_maintenance_counts_roads_and_railways_the_nation_holds() {
@@ -112,16 +128,16 @@ export class ImprovementsTest extends TestCase {
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
this.assertApprox(
state.getTransportUpkeep(0) - before,
- TRANSPORT_BY_ID.road.maintenance,
+ RESOURCE_RULES.transportUpkeepSteel.road,
1e-9,
- "a road adds its hourly maintenance"
+ "a road adds its daily steel wear"
);
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
this.assertApprox(
state.getTransportUpkeep(0) - before,
- TRANSPORT_BY_ID.railway.maintenance,
+ RESOURCE_RULES.transportUpkeepSteel.railway,
1e-9,
- "the railway replaces the road's maintenance"
+ "the railway replaces the road's wear"
);
}
@@ -176,4 +192,37 @@ export class ImprovementsTest extends TestCase {
this.assertTrue(state.railways.has(k), "a city carries a railway");
this.assertFalse(state.roads.has(k), "not a road");
}
+
+ test_a_tile_work_costs_steel_energy_and_high_tech() {
+ const state = smallState();
+ state.budgets.set(0, 1.0e15);
+ // Enough stores that only the electricity is bought, so the money bill is
+ // the build cost plus the construction energy.
+ for (const city of state.cities) {
+ if (city.civ !== 0) continue;
+ const stock = state.getCityResourceStock(city);
+ stock.steel = 1e9;
+ stock.hightech = 1e6;
+ }
+ const coords = state.landCells.find((cell) => {
+ const k = key(cell.x, cell.y);
+ return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
+ });
+ this.assertNotNull(coords, "the player owns a bare land tile");
+ // A radar tower is advanced, so it also spends high-tech.
+ const radar = TILE_IMPROVEMENT_BY_ID.radar_tower;
+ const materials = tileImprovementResourceCost(radar);
+ this.assertGreater(materials.steel, 0, "steel is spent");
+ this.assertGreater(materials.energy, 0, "energy is spent");
+ this.assertGreater(materials.hightech, 0, "high-tech is spent");
+ const before = state.getBudget(0);
+ this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower"));
+ const energyCost = materials.energy * state.getResourcePrice("energy");
+ this.assertApprox(
+ before - state.getBudget(0),
+ radar.buildCost + energyCost,
+ 1e-3,
+ "money plus the construction electricity"
+ );
+ }
}
diff --git a/tests/map_generator_test.js b/tests/map_generator_test.js
index 3833852..590447f 100644
--- a/tests/map_generator_test.js
+++ b/tests/map_generator_test.js
@@ -123,7 +123,7 @@ export class MapGeneratorTest extends TestCase {
this.assertHas(tile, "terrainClass");
this.assertHas(tile, "movementCostMultiplier");
this.assertHas(tile, "populationMultiplier");
- this.assertHas(tile, "gdpMultiplier");
+ this.assertHas(tile, "productionMultiplier");
}
}
diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js
index 1eb8ffa..e15dca5 100644
--- a/tests/map_modes_test.js
+++ b/tests/map_modes_test.js
@@ -98,8 +98,8 @@ export class MapModesTest extends TestCase {
this.assertFalse(map.political, "the political fill is off");
this.assertFalse(env.$("#map-legend").hasClass("hidden"), "the legend is shown");
this.assertEqual(env.$("#map-legend-title").text(), "GDP per capita", "the legend is titled");
- this.assertEqual(env.$("#map-legend-min").text(), "€100", "the legend shows the minimum");
- this.assertEqual(env.$("#map-legend-max").text(), "€1 234", "the legend shows the maximum");
+ this.assertEqual(env.$("#map-legend-min").text(), "¤100", "the legend shows the minimum");
+ this.assertEqual(env.$("#map-legend-max").text(), "¤1 234", "the legend shows the maximum");
// Every land tile carries the top-of-range colour, so the fill is live.
const top = economicColour(1234, 100, 1234);
let painted = false;
@@ -140,6 +140,25 @@ export class MapModesTest extends TestCase {
}
}
+ async test_the_delivery_graph_shows_only_in_economic_modes() {
+ const state = smallState();
+ const env = await setupDom();
+ const map = this.buildMap(env);
+ try {
+ map.applySnapshot(state.snapshot(0));
+ map.setMapMode("terrain");
+ map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
+ this.assertEmpty(env.$("#layer-resources .resource-node"), "hidden on the terrain map");
+ this.assertEmpty(env.$("#layer-resources .border-seg"), "and no delivery legs either");
+ map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
+ map.setMapMode("population");
+ this.assertNotEmpty(env.$("#layer-resources .resource-node"), "shown in an economic mode");
+ this.assertNotEmpty(env.$("#layer-resources .border-seg"), "the delivery leg is drawn");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_fog_rebuilds_are_coalesced_between_frames() {
const env = await setupDom();
const map = this.buildMap(env);
diff --git a/tests/map_view_test.js b/tests/map_view_test.js
index 58f7e1d..d1c81bb 100644
--- a/tests/map_view_test.js
+++ b/tests/map_view_test.js
@@ -12,8 +12,14 @@ export class MapViewTest extends TestCase {
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
map.$viewport.width = () => 800;
map.$viewport.height = () => 600;
- map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
const snapshot = state.snapshot(0);
+ // The snapshot ships the nations in their seeded order; rebuild the full
+ // catalogue in that same order so owner indices line up, exactly as the
+ // game screen does.
+ const catalogue = snapshot.civs.map(
+ (c) => CIVILISATIONS.find((full) => full.id === c.id) || c
+ );
+ map.setCatalogue(catalogue, PROTO_UNITS);
map.applySnapshot(snapshot);
return { env, map, snapshot };
}
@@ -127,13 +133,14 @@ export class MapViewTest extends TestCase {
async test_unit_views_show_their_owners_flag() {
const state = smallState();
- const { env } = await this.build(state);
+ const { env, map } = await this.build(state);
try {
const flags = env.$("#layer-entities .unit .unit-flag");
this.assertSize(flags, state.units.length, "every unit carries a flag");
const unit = state.units[0];
const civ = CIVILISATIONS.find((c) => c.id === state.civilisations[unit.civ].id);
- const src = env.$("#layer-entities .unit").first().find(".unit-flag").attr("src") || "";
+ const view = map._unitViews.get(unit.id);
+ const src = (view && view.querySelector(".unit-flag").getAttribute("src")) || "";
this.assertTrue(src.endsWith(civ.flag), `the flag is the owner's: ${src}`);
} finally {
teardownDom(env);
diff --git a/tests/migration_test.js b/tests/migration_test.js
new file mode 100644
index 0000000..b401f75
--- /dev/null
+++ b/tests/migration_test.js
@@ -0,0 +1,109 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState, cityOf } from "./framework/helpers.js";
+import { key } from "../shared/hex.js";
+
+export class MigrationTest extends TestCase {
+ test_food_output_comes_from_the_land_not_the_headcount() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const before = state._regionProductionValue(city);
+ // Pack many more people onto the same land: the harvest must not change.
+ for (const coords of state.regionTiles(city)) {
+ const k = key(coords.x, coords.y);
+ state.tilePopulation.set(k, (state.tilePopulation.get(k) || 0) * 4 + 1);
+ }
+ state._gdpPerCapitaCache.clear();
+ const after = state._regionProductionValue(city);
+ this.assertApprox(after, before, 1e-6, "the land yields the same however many live on it");
+ }
+
+ test_regions_of_a_nation_open_with_equal_income_per_head() {
+ const state = smallState();
+ for (let civ = 0; civ < state.civilisations.length; civ++) {
+ const cities = state.cities.filter((city) => city.civ === civ);
+ if (cities.length < 2) continue;
+ const perHead = cities.map((city) => {
+ const economy = state.getCityEconomy(city);
+ return economy.population > 0 ? economy.gdp / economy.population : 0;
+ });
+ const highest = Math.max(...perHead);
+ const lowest = Math.min(...perHead);
+ this.assertTrue(
+ highest > 0 && (highest - lowest) / highest <= 0.02,
+ `regions of civ ${civ} open level`
+ );
+ }
+ }
+
+ test_moving_people_conserves_the_total() {
+ const state = smallState();
+ const cities = state.cities.filter((city) => city.civ === 0);
+ this.assertGreaterOrEqual(cities.length, 2, "the fixture has two regions");
+ const from = cities[0];
+ const to = cities[1];
+ const fromBefore = state.regionPopulation(from);
+ const toBefore = state.regionPopulation(to);
+ const moved = state._movePopulation(from, to, 100_000, false);
+ this.assertApprox(moved, 100_000, 1);
+ this.assertApprox(state.regionPopulation(from), fromBefore - moved, 1e-6);
+ this.assertApprox(state.regionPopulation(to), toBefore + moved, 1e-6);
+ }
+
+ test_people_move_toward_the_richer_region() {
+ const state = smallState();
+ const cities = state.cities.filter((city) => city.civ === 0);
+ const rich = cities[0];
+ const poor = cities[1];
+ // Dense on one side, sparse on the other: with land-based food that makes
+ // the sparse region the rich one.
+ for (const coords of state.regionTiles(rich)) {
+ state.tilePopulation.set(key(coords.x, coords.y), 1_000);
+ }
+ for (const coords of state.regionTiles(poor)) {
+ state.tilePopulation.set(key(coords.x, coords.y), 5_000_000);
+ }
+ state._gdpPerCapitaCache.clear();
+ const richBefore = state.regionPopulation(rich);
+ const poorBefore = state.regionPopulation(poor);
+ state._migrateForIncome();
+ this.assertGreater(state.regionPopulation(rich), richBefore, "people moved to the rich region");
+ this.assertLess(state.regionPopulation(poor), poorBefore, "and left the poor one");
+ }
+
+ test_a_region_only_moves_to_a_much_richer_one() {
+ const state = smallState();
+ // Migration levelled the regions at generation, so no one is twice as rich
+ // as anyone else and nobody moves.
+ const before = state.cities.map((city) => state.regionPopulation(city));
+ state._migrateForIncome();
+ const after = state.cities.map((city) => state.regionPopulation(city));
+ for (let i = 0; i < before.length; i++) {
+ this.assertApprox(after[i], before[i], 1e-6, "nobody moved to a merely equal region");
+ }
+ }
+
+ test_an_airport_caps_its_daily_intake() {
+ const state = smallState();
+ const cities = state.cities;
+ const from = cityOf(state, 0);
+ const to = cities.find((city) => city.civ === 1);
+ // Every region is dense and equally poor except `to`, which is sparse and
+ // so far richer. Only `to` is worth moving to.
+ for (const city of cities) {
+ const people = city.id === to.id ? 1_000 : 5_000_000;
+ for (const coords of state.regionTiles(city)) {
+ state.tilePopulation.set(key(coords.x, coords.y), people);
+ }
+ }
+ state._gdpPerCapitaCache.clear();
+ const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
+ from.buildings[airport] = 1;
+ to.buildings[airport] = 1;
+ const fromBefore = state.regionPopulation(from);
+ state._migrateForIncome();
+ const lost = fromBefore - state.regionPopulation(from);
+ this.assertGreater(lost, 0, "people flew to the rich region");
+ // 1000 people per airport level a day, and the route's level is 1.
+ this.assertTrue(lost <= 1_001, `the flight carried at most its capacity, lost ${lost}`);
+ }
+}
diff --git a/tests/modals_test.js b/tests/modals_test.js
index 47ddbd8..1d936bc 100644
--- a/tests/modals_test.js
+++ b/tests/modals_test.js
@@ -2,7 +2,8 @@ import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { CityModal, NationModal, ConfirmModal, NewsModal } from "../client/js/modals.js";
import { buildingEffectText, describeNews } from "../client/js/modals/format.js";
-import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS } from "../shared/data.js";
+import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, technologyCost } from "../shared/data.js";
+import { groupDigits } from "../shared/text_format.js";
export class CityModalTest extends TestCase {
test_utility_buildings_describe_their_mechanic() {
@@ -14,7 +15,7 @@ export class CityModalTest extends TestCase {
const shelters = BUILDINGS.find((b) => b.id === "shelters");
this.assertTrue(buildingEffectText(shelters, 2).includes("20%"), "the shelters show their protection");
const market = BUILDINGS.find((b) => b.id === "market");
- this.assertTrue(buildingEffectText(market, 1).includes("GDP/capita"), "stats keep their effect text");
+ this.assertTrue(buildingEffectText(market, 1).includes("production"), "stats keep their effect text");
}
showTrain(modal, queue = []) {
modal.show(
@@ -165,13 +166,15 @@ export class CityModalTest extends TestCase {
}
}
- async test_unaffordable_upgrade_is_disabled() {
+ async test_upgrade_stays_available_on_an_empty_treasury() {
const env = await setupDom();
try {
const modal = new CityModal();
modal.show({ id: 1, name: "X" }, { budget: 0, buildings: BUILDINGS, levels: {} });
const upgrade = env.$("#city-buildings-list .row-card").first().find(".actions button").first();
- this.assertTrue(upgrade.prop("disabled"));
+ // The government can always pay, running an overdraft, so cost never
+ // disables the button; only an unmet requirement does.
+ this.assertFalse(upgrade.prop("disabled"));
} finally {
teardownDom(env);
}
@@ -208,13 +211,13 @@ export class CityModalTest extends TestCase {
};
modal.show({ id: 1, name: "Paris" }, { budget: 1.0e15, buildings: BUILDINGS, levels: {} });
modal.renderBudget(breakdown);
- this.assertTrue(env.$("#city-budget-sub").text().includes("+€635/h"), "the region net is shown");
+ this.assertTrue(env.$("#city-budget-sub").text().includes("+¤635/h"), "the region net is shown");
const text = env.$("#city-budget-list").text();
- this.assertTrue(text.includes("+€1 035/h"), "region tax income is green");
+ this.assertTrue(text.includes("+¤1 035/h"), "region tax income is green");
this.assertTrue(text.includes("Bank in Paris (building) +5.0%"), "a building bonus is listed");
- this.assertTrue(text.includes("−€400/h"), "the building upkeep is listed");
+ this.assertTrue(text.includes("−¤400/h"), "the building upkeep is listed");
this.assertTrue(text.includes("TOTAL"), "a total row is present");
- this.assertTrue(text.includes("+€635/h"), "the total carries the region net");
+ this.assertTrue(text.includes("+¤635/h"), "the total carries the region net");
this.assertFalse(text.includes("No units"), "unit upkeep is national, not regional");
env.$("#city-budget-list tr.budget-group .budget-name").click();
@@ -244,6 +247,36 @@ export class CityModalTest extends TestCase {
teardownDom(env);
}
}
+
+ async test_economy_tab_shows_the_regions_cash_basket() {
+ const env = await setupDom();
+ try {
+ const modal = new CityModal();
+ modal.show(
+ { id: 1, name: "Lyon" },
+ {
+ budget: 1.0e9,
+ gdp: 1.0e9,
+ cash: { FRF: 4000, GBP: 250 },
+ cashValue: 4250,
+ debt: 250,
+ currencies: [
+ { code: "FRF", symbol: "₣", name: "French Franc" },
+ { code: "GBP", symbol: "£", name: "British Pound" },
+ ],
+ }
+ );
+ env.$("#modal-city .tab[data-tab=economy]").click();
+ this.assertTrue(env.$("#city-tab-economy").hasClass("active"));
+ const text = env.$("#city-economy-list").text();
+ this.assertTrue(text.includes("French Franc"), "the national currency is listed");
+ this.assertTrue(text.includes("British Pound"), "a foreign holding is listed");
+ this.assertTrue(env.$("#city-economy-sub").text().includes("4 250"), "the total value shows");
+ this.assertTrue(env.$("#city-economy-sub").text().includes("Debt"), "the central-bank debt shows");
+ } finally {
+ teardownDom(env);
+ }
+ }
}
export class NationModalTest extends TestCase {
@@ -334,6 +367,90 @@ export class NationModalTest extends TestCase {
}
}
+ async test_resources_tab_lists_stores_needs_and_the_market() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ const summary = {
+ stock: { steel: 1000, food: 2_000_000, luxury: 5000, hightech: 12 },
+ need: { steel: 10, food: 20, luxury: 30, hightech: 0 },
+ production: { steel: 5, food: 0, luxury: 0, hightech: 1 },
+ consumed: { steel: 15, food: 25, luxury: 35, hightech: 2, energy: 1.1e9 },
+ energy: { need: 1.0e9, supply: 2.0e9 },
+ trade: { imports: { steel: 40 }, exports: { steel: 7 } },
+ prices: {},
+ };
+ const market = [
+ { id: "energy", price: 0.003, supply: 1.0e9, demand: 2.0e9 },
+ { id: "steel", price: 600, supply: 100, demand: 200 },
+ { id: "food", price: 300, supply: 50, demand: 60 },
+ { id: "luxury", price: 60, supply: 0, demand: 10 },
+ { id: "hightech", price: 800, supply: 0, demand: 0 },
+ ];
+ modal.show(
+ GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5,
+ {}, null, [], null, null, { summary, market }
+ );
+ env.$("#modal-nation .tab[data-tab=resources]").click();
+ this.assertTrue(env.$("#tab-resources").hasClass("active"), "the tab opens");
+ const rows = env.$("#resources-list .resources-table tbody tr");
+ this.assertSize(rows, 5, "one row for every resource");
+ const energy = rows.eq(0);
+ this.assertTrue(energy.find(".name").text().includes("Energy"), "energy leads the list");
+ this.assertEqual(energy.find("td").eq(1).text(), "—", "energy has no store");
+ const steel = rows.eq(1);
+ this.assertTrue(steel.find(".name").text().includes("Steel"));
+ this.assertTrue(steel.find("td").eq(2).text().includes("15"), "consumption is shown");
+ this.assertTrue(steel.find("td").eq(4).text().includes("7"), "exports are shown");
+ this.assertTrue(steel.find("td").eq(5).text().includes("40"), "imports are shown");
+ this.assertTrue(steel.find("td").eq(6).text().includes("600"), "the steel price is shown");
+ this.assertTrue(steel.find("td").eq(7).text().length > 0, "world supply is shown");
+ modal.refresh(
+ 0, new Set(), 1.0e9, 1.0e15, 5, {}, null, null, null, null,
+ { summary, market }
+ );
+ this.assertSize(env.$("#resources-list .resources-table tbody tr"), 5, "refreshing keeps the rows");
+ modal.close();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_resources_tab_ranks_major_exporters_and_importers() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ const summary = {
+ stock: { steel: 0, food: 0, luxury: 0, hightech: 0 },
+ need: { steel: 0, food: 0, luxury: 0, hightech: 0 },
+ production: { steel: 0, food: 0, luxury: 0, hightech: 0 },
+ consumed: { steel: 0, food: 0, luxury: 0, hightech: 0, energy: 0 },
+ energy: { need: 0, supply: 0 },
+ trade: { imports: {}, exports: {} },
+ prices: {},
+ };
+ const trade = [
+ { civ: 0, imports: { steel: 10 }, exports: { steel: 100 } },
+ { civ: 1, imports: { steel: 50 }, exports: { steel: 5 } },
+ ];
+ const civs = [{ name: "France" }, { name: "Britain" }];
+ modal.show(
+ GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5,
+ {}, null, [], null, null, { summary, market: [], trade, civs }
+ );
+ env.$("#modal-nation .tab[data-tab=resources]").click();
+ env.$("#resources-subtabs .subtab[data-view=leaders]").click();
+ const rows = env.$("#resource-trade-list .resources-table tbody tr");
+ this.assertSize(rows, 5, "one row for every resource");
+ const steel = rows.eq(1);
+ this.assertTrue(steel.find("td").eq(1).text().includes("France"), "the major exporter leads");
+ this.assertTrue(steel.find("td").eq(3).text().includes("Britain"), "the major importer leads");
+ modal.close();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_a_focused_technology_shows_a_progress_bar() {
const env = await setupDom();
try {
@@ -346,7 +463,11 @@ export class NationModalTest extends TestCase {
});
const card = env.$("#technology-list .row-card").eq(method);
this.assertTrue(card.find(".tech-progress-track").length > 0, "the focus has a bar");
- this.assertEqual(card.find(".tech-progress-text").text(), "10 / 20", "X / Y beside it");
+ this.assertEqual(
+ card.find(".tech-progress-text").text(),
+ `10 / ${groupDigits(technologyCost(TECHNOLOGIES[method]))}`,
+ "X / Y beside it"
+ );
this.assertEqual(card.find(".actions button").text(), "Focused", "no cost on the button");
modal.close();
} finally {
@@ -393,38 +514,49 @@ export class NationModalTest extends TestCase {
}
}
- async test_budget_tab_shows_income_and_upkeep_breakdown() {
+ async test_budget_tab_shows_monthly_income_and_expenses() {
const env = await setupDom();
try {
const modal = new NationModal();
+ const month = (overrides) => ({
+ monthIndex: 24000,
+ isCurrent: true,
+ label: "January 2000",
+ income: 0,
+ sales: 0,
+ totalCash: 0,
+ totalEconomic: 0,
+ net: 0,
+ expenses: [],
+ resourceTrade: [],
+ ...overrides,
+ });
const finances = {
- income: {
- rate: 0.001,
- base: 900,
- modifier: 0.15,
- total: 1035,
- sources: [
- { label: "Bank", kind: "building", effect: 0.05, amount: 45, cityId: 1, cityName: "Paris" },
- { label: "Republic", kind: "government", effect: 0.1, amount: 90 },
- ],
- },
- buildingUpkeep: {
- total: 400,
- sources: [{ label: "Bank", kind: "building", amount: 400 }],
- groups: [
- {
- index: 1,
- label: "Bank",
- amount: 400,
- cities: [{ cityId: 1, cityName: "Paris", level: 2, amount: 400 }],
- },
- ],
- },
- unitUpkeep: {
- total: 150,
- sources: [{ label: "Modern infantry", kind: "unit", amount: 150, count: 3 }],
- },
- net: 485,
+ months: [
+ month({
+ monthIndex: 23999,
+ isCurrent: false,
+ label: "December 1999",
+ income: 500,
+ totalCash: 400,
+ totalEconomic: 1200,
+ net: 100,
+ expenses: [["buildings", 400, 1200]],
+ }),
+ month({
+ income: 1035,
+ sales: 60,
+ totalCash: 550,
+ totalEconomic: 900,
+ net: 545,
+ expenses: [
+ ["buildings", 400, 700, [["steel", 250], ["hightech", 150]]],
+ ["units", 150, 200, []],
+ ],
+ resourceTrade: [["steel", 220, 0], ["food", 0, 90]],
+ }),
+ ],
+ cities: [],
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, finances);
env.$("#modal-nation .tab[data-tab=budget]").click();
@@ -432,31 +564,44 @@ export class NationModalTest extends TestCase {
this.assertSize(env.$("#budget-list table.budget-table"), 1);
const text = env.$("#budget-list").text();
- this.assertTrue(text.includes("+€1 035/h"), "tax income is green");
- this.assertTrue(text.includes("Bank in Paris (building) +5.0%"), "a building bonus is listed");
- this.assertTrue(text.includes("Republic (government) +10.0%"), "a government bonus is listed");
- this.assertTrue(text.includes("−€400/h"), "the building group shows its total cost");
- this.assertTrue(text.includes("Modern infantry ×3"), "unit upkeep is listed");
+ this.assertTrue(text.includes("+¤1 035"), "the month's tax income is shown");
+ this.assertTrue(text.includes("Resource sales"), "resource sales are shown");
+ this.assertTrue(text.includes("Building upkeep"), "a building category is listed");
+ this.assertTrue(text.includes("−¤400"), "the building cash is listed");
+ this.assertTrue(text.includes("materials valued at ¤700"), "the market value is noted");
+ this.assertTrue(text.includes("Unit upkeep"), "a unit category is listed");
+ this.assertTrue(text.includes("Bought Steel"), "a bought resource is listed");
+ this.assertTrue(text.includes("Sold Food"), "a sold resource is listed");
this.assertTrue(text.includes("TOTAL"), "a total row is present");
- this.assertTrue(text.includes("+€485/h"), "the total carries the net balance");
+ this.assertTrue(text.includes("+¤545"), "the total carries the month net");
this.assertTrue(env.$("#budget-sub").text().includes("Treasury"));
+ this.assertTrue(env.$("#budget-sub").text().includes("January 2000"), "the month is named");
- // Expanding the building type reveals the per-city cost.
- env.$("#budget-list tr.budget-group .budget-name").click();
+ // Expanding an upkeep row names the resources it bought.
+ env.$("#budget-list tr .budget-name.clickable").first().click();
+ const expanded = env.$("#budget-list").text();
+ this.assertTrue(expanded.includes("Steel"), "the expanded row names steel");
+ this.assertTrue(expanded.includes("−¤250"), "and what it paid for it");
+
+ // The pager steps back to the previous month's digest.
+ env.$("#budget-months .budget-month-nav").first().click();
this.assertTrue(
- env.$("#budget-list").text().includes("Bank in Paris — level 2"),
- "expanding a building type shows its cities"
+ env.$("#budget-sub").text().includes("December 1999"),
+ "the pager names the older month"
);
modal.refresh(0, new Set(), 1.0e9, 1.0e15, 1.0e6, {
- income: { rate: 0.001, base: 100, modifier: 0, total: 100, sources: [] },
- buildingUpkeep: { total: 400, sources: [], groups: [] },
- unitUpkeep: { total: 150, sources: [], groups: [] },
- net: -450,
+ months: [month({
+ income: 100,
+ totalCash: 400,
+ totalEconomic: 400,
+ net: -300,
+ expenses: [["buildings", 400, 400]],
+ })],
+ cities: [],
});
const refreshed = env.$("#budget-list").text();
- this.assertTrue(refreshed.includes("−€450/h"), "the total updates");
- this.assertTrue(refreshed.includes("No bonuses"), "empty income sources are named");
+ this.assertTrue(refreshed.includes("−¤300"), "the total updates");
} finally {
teardownDom(env);
}
@@ -522,8 +667,8 @@ export class NationModalTest extends TestCase {
this.assertTrue(rows.eq(0).find(".name").text() === "Britain", "alphabetical by default");
this.assertTrue(rows.eq(0).find(".status").text().includes("At peace"));
const numbers = rows.eq(0).find("td.numeric");
- this.assertTrue(numbers.eq(0).text().includes("€3B"), "Britain's GDP shows");
- this.assertTrue(numbers.eq(1).text().includes("€2K"), "Britain's GDP/capita shows");
+ this.assertTrue(numbers.eq(0).text().includes("¤3B"), "Britain's GDP shows");
+ this.assertTrue(numbers.eq(1).text().includes("¤2K"), "Britain's GDP/capita shows");
this.assertTrue(numbers.eq(2).text().includes("1.5M"), "Britain's population shows");
const own = rows.eq(1);
@@ -586,6 +731,54 @@ export class NationModalTest extends TestCase {
teardownDom(env);
}
}
+
+ async test_economy_tab_shows_private_cash_central_bank_and_rates() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ const money = {
+ viewerCiv: 0,
+ currency: { code: "FRF", symbol: "₣", name: "French Franc" },
+ currencies: [
+ { civ: 0, code: "FRF", symbol: "₣", name: "French Franc", value: 1 },
+ { civ: 1, code: "GBP", symbol: "£", name: "British Pound", value: 1 },
+ ],
+ exchangeRates: [
+ { civ: 0, code: "FRF", symbol: "₣", name: "French Franc", rate: 1 },
+ { civ: 1, code: "GBP", symbol: "£", name: "British Pound", rate: 0.85 },
+ ],
+ centralBank: {
+ rate: 0.02,
+ currency: { code: "FRF", symbol: "₣", name: "French Franc" },
+ reserves: [{ code: "GBP", symbol: "£", name: "British Pound", amount: 1234 }],
+ loans: 987654,
+ },
+ privateCash: 5000,
+ cities: [{ id: 1, civ: 0, name: "Paris", cashValue: 5000 }],
+ };
+ modal.show(
+ GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6,
+ { months: [], cities: [] }, null, [], null, null, null, money
+ );
+ env.$("#modal-nation .tab[data-tab=economy]").click();
+ this.assertTrue(env.$("#tab-economy").hasClass("active"));
+ const summary = env.$("#economy-summary").text();
+ this.assertTrue(summary.includes("Paris"), "the region is listed");
+ this.assertTrue(summary.includes("5 000"), "its private cash is shown");
+
+ env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
+ const bank = env.$("#economy-central-bank").text();
+ this.assertTrue(bank.includes("British Pound"), "the foreign reserve is listed");
+ this.assertTrue(bank.includes("987 654"), "the outstanding loans are listed");
+ this.assertTrue(env.$("#cb-rate").length === 1, "the rate input is present");
+
+ env.$("#economy-subtabs .subtab[data-view=rates]").click();
+ this.assertTrue(env.$("#economy-rates").text().includes("British Pound"), "the rate table shows");
+ this.assertTrue(env.$("#economy-rates").text().includes("0.8500"), "the rate is shown");
+ } finally {
+ teardownDom(env);
+ }
+ }
}
export class ConfirmModalTest extends TestCase {
diff --git a/tests/money_test.js b/tests/money_test.js
new file mode 100644
index 0000000..822e0f5
--- /dev/null
+++ b/tests/money_test.js
@@ -0,0 +1,269 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState, cityOf } from "./framework/helpers.js";
+import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
+import { MONEY } from "../shared/data/economy.js";
+
+export class MoneyTest extends TestCase {
+ // The money in the world: treasuries, region baskets and bank reserves, less
+ // every debt owed to a bank. Rates are frozen by the caller so revaluation
+ // does not move the measure.
+ totalMoney(state) {
+ let total = 0;
+ for (let civ = 0; civ < state.civilisations.length; civ++) {
+ total += state.getBudget(civ);
+ for (const city of state.cities) {
+ if (city.civ !== civ) continue;
+ total -= state.getRegionDebt(city);
+ }
+ for (const [, amount] of state.getCentralBankDebt(civ)) total -= amount;
+ }
+ for (const city of state.cities) {
+ for (const [, amount] of state.getRegionCash(city)) total += amount;
+ }
+ for (const reserves of state.centralBankReserves.values()) {
+ for (const [, amount] of reserves) total += amount;
+ }
+ return total;
+ }
+
+ test_government_spending_is_paid_into_the_regions() {
+ const state = smallState();
+ const code = state.currencyOf(0).code;
+ const cities = state.cities.filter((city) => city.civ === 0);
+ const before = cities.map((city) => state.getRegionCash(city).get(code) || 0);
+ const budgetBefore = state.getBudget(0);
+ state._spendBudget(0, 1_000_000);
+ const after = cities.map((city) => state.getRegionCash(city).get(code) || 0);
+ const reached = after.reduce((sum, value, index) => sum + value - before[index], 0);
+ this.assertApprox(reached, 1_000_000, 1, "the money reached the private sector");
+ this.assertApprox(state.getBudget(0), budgetBefore - 1_000_000, 1e-6);
+ }
+
+ test_taxes_are_taken_from_the_regions() {
+ const state = smallState();
+ const code = state.currencyOf(0).code;
+ const cities = state.cities.filter((city) => city.civ === 0);
+ const holding = () => cities.reduce(
+ (sum, city) => sum + (state.getRegionCash(city).get(code) || 0), 0
+ );
+ const before = holding();
+ state._chargeRegions(0, 500_000);
+ this.assertApprox(before - holding(), 500_000, 1, "the tax came out of the regions");
+ }
+
+ test_a_spend_neither_creates_nor_destroys_money() {
+ const state = smallState();
+ // Hold the exchange rates still so the measure reads units, not revaluation.
+ state.setCurrencyValue = () => {};
+ const before = this.totalMoney(state);
+ state._spendBudget(0, 5_000_000, "training");
+ this.assertApprox(this.totalMoney(state), before, 1, "the spend moved money, it did not mint it");
+ }
+
+ test_a_market_purchase_neither_creates_nor_destroys_money() {
+ const state = smallState();
+ state.setCurrencyValue = () => {};
+ const city = cityOf(state, 0);
+ const before = this.totalMoney(state);
+ state._chargeRegionCash(city, state.currencyOf(0).code, 2_000_000);
+ state._payWorld(0, 2_000_000, state.currencyOf(0).code);
+ this.assertApprox(this.totalMoney(state), before, 1, "the import stayed in the world");
+ }
+
+ test_every_civilisation_has_a_currency() {
+ const state = smallState();
+ const codes = new Set();
+ for (let civ = 0; civ < state.civilisations.length; civ++) {
+ const currency = state.currencyOf(civ);
+ this.assertTrue(currency.name, "the currency is named");
+ this.assertTrue(currency.code, "the currency has a code");
+ this.assertTrue(currency.symbol, "the currency has a symbol");
+ codes.add(currency.code);
+ }
+ this.assertEqual(codes.size, state.civilisations.length, "codes are unique");
+ }
+
+ test_regions_open_with_private_cash_in_their_own_currency() {
+ const state = smallState();
+ for (const city of state.cities) {
+ const basket = state.getRegionCash(city);
+ this.assertSize(basket, 1, "a region opens holding one currency");
+ const code = state.currencyOf(city.civ).code;
+ this.assertTrue((basket.get(code) || 0) > 0, "its own national currency");
+ this.assertGreater(state.getRegionCashValue(city), 0, "the reserve has value");
+ }
+ }
+
+ test_a_short_region_borrows_from_the_central_bank() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const code = state.currencyOf(0).code;
+ state.getRegionCash(city).set(code, 0);
+ state._chargeRegionCash(city, code, 500);
+ this.assertApprox(state.getRegionDebt(city), 500, 1e-6, "the shortfall became debt");
+ }
+
+ test_region_debt_accrues_interest_daily() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ // No cash to repay with, so the debt compounds.
+ state.getRegionCash(city).set(state.currencyOf(0).code, 0);
+ state.regionDebt.set(city.id, 1000);
+ state._tickMoney();
+ this.assertApprox(
+ state.getRegionDebt(city), 1000 * (1 + MONEY.defaultInterestRate / 365), 1e-6
+ );
+ }
+
+ test_setting_the_rate_changes_the_debt_interest() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ this.assertTrue(state.requestSetInterestRate(0, 0.1));
+ state.getRegionCash(city).set(state.currencyOf(0).code, 0);
+ state.regionDebt.set(city.id, 1000);
+ state._tickMoney();
+ this.assertApprox(state.getRegionDebt(city), 1000 * (1 + 0.1 / 365), 1e-6);
+ }
+
+ test_the_government_can_run_an_overdraft() {
+ const state = smallState();
+ state.budgets.set(0, 100);
+ this.assertTrue(state._spendBudget(0, 1000, "training"), "a spend is never refused");
+ this.assertLess(state.getBudget(0), 0, "the treasury went into the red");
+ this.assertApprox(state.getGovernmentDebt(0), -state.getBudget(0), 1e-6);
+ }
+
+ test_the_rate_is_clamped_and_rejects_nonsense() {
+ const state = smallState();
+ state.requestSetInterestRate(0, 5);
+ this.assertEqual(state.getCentralBankRate(0), MONEY.maxInterestRate, "clamped to the ceiling");
+ this.assertFalse(state.requestSetInterestRate(0, -1), "a negative rate is refused");
+ this.assertFalse(state.requestSetInterestRate(99, 0.02), "an unknown nation is refused");
+ }
+
+ test_the_private_sector_aggregates_its_regions() {
+ const state = smallState();
+ let sum = 0;
+ for (const city of state.cities) {
+ if (city.civ !== 0) continue;
+ sum += state.getRegionCashValue(city);
+ }
+ this.assertApprox(state.getPrivateSectorCash(0), sum, 1e-6);
+ }
+
+ test_exchange_rates_follow_the_currency_values() {
+ const state = smallState();
+ this.assertApprox(state.exchangeRate(0, 1), 1, 1e-9, "currencies start at par");
+ state.setCurrencyValue(1, 0.5);
+ this.assertApprox(state.exchangeRate(0, 1), 2, 1e-9, "a cheaper currency buys fewer");
+ }
+
+ test_cross_border_trade_settles_through_the_central_bank() {
+ const state = smallState();
+ const buyer = cityOf(state, 0);
+ const seller = cityOf(state, 1);
+ state.getCityResourceStock(seller).food = 1e9;
+ state.getCityResourceStock(buyer).food = 0;
+ const buyerCode = state.currencyOf(0).code;
+ const sellerCode = state.currencyOf(1).code;
+ const buyerBefore = state.getRegionCash(buyer).get(buyerCode) || 0;
+ const sellerBefore = state.getRegionCash(seller).get(sellerCode) || 0;
+ const bought = state._procureFromNeighbours(
+ buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes()
+ );
+ this.assertGreater(bought, 0, "the foreign neighbour sold food");
+ this.assertLess(
+ state.getRegionCash(buyer).get(buyerCode) || 0, buyerBefore, "the buyer spent its own currency"
+ );
+ this.assertGreater(
+ state.getRegionCash(seller).get(sellerCode) || 0,
+ sellerBefore,
+ "the seller was paid in its own currency"
+ );
+ this.assertGreater(
+ state.getCentralBankReserves(1).get(buyerCode) || 0,
+ 0,
+ "the seller's central bank kept the buyer's currency as a reserve"
+ );
+ }
+
+ test_a_currency_in_demand_strengthens() {
+ const state = smallState();
+ const before = state.currencyValue(0);
+ state._recordCurrencyDemand(0, 1e12);
+ state._tickMoney();
+ this.assertGreater(state.currencyValue(0), before, "net demand lifts the rate");
+ }
+
+ test_regions_repatriate_foreign_cash() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const national = state.currencyOf(0).code;
+ const foreign = state.currencyOf(1).code;
+ const basket = state.getRegionCash(city);
+ basket.set(foreign, 1000);
+ const nationalBefore = basket.get(national) || 0;
+ state._tickMoney();
+ this.assertLess(basket.get(foreign) || 0, 1000, "the foreign holding shrank");
+ this.assertGreater(basket.get(national) || 0, nationalBefore, "national cash grew");
+ }
+
+ test_a_broke_region_buys_by_borrowing() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
+ const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
+ if (!coastal) return;
+ coastal.buildings[portIndex] = 1;
+ this.assertTrue(state._civMarketAccess(0), "a port opens the market");
+ state.getRegionCash(city).set(state.currencyOf(0).code, 0);
+ const store = state.getCityResourceStock(city);
+ const bought = state._buyMarketMaterial(city, "food", 100, store);
+ this.assertGreater(bought, 0, "the purchase still goes through");
+ this.assertGreater(state.getRegionDebt(city), 0, "it was funded by borrowing");
+ }
+
+ test_a_contested_surplus_is_bid_up() {
+ const state = smallState();
+ const buyer = cityOf(state, 0);
+ const nodes = state._resourceNodes();
+ const supplier = state._nearbySuppliers(buyer, "food", nodes)[0];
+ this.assertNotNull(supplier, "a supplier is reachable");
+ // Give that one node a huge surplus and drain every other store of food.
+ for (const [, stock] of state.resourceStock) stock.food = 0;
+ supplier.stock.food = 1e9;
+ state.getCityResourceStock(buyer).food = 0;
+ const receiverCity = supplier.city
+ || state._regionCityNear(supplier.civ, supplier.coords);
+ const code = state.currencyOf(supplier.civ).code;
+ const vSeller = state.currencyValue(supplier.civ);
+ // Pretend another buyer already claimed most of the surplus.
+ const surplus = state._nodeSurplus(supplier, "food");
+ state._nodeClaims = new Map([
+ [`${supplier.coords.x},${supplier.coords.y}`, surplus * 0.9],
+ ]);
+ const before = state.getRegionCash(receiverCity).get(code) || 0;
+ const store = state.getCityResourceStock(buyer);
+ const bought = state._procureFromNeighbours(
+ buyer, "food", 1000, store, state._resourceNodes()
+ );
+ this.assertGreater(bought, 0, "food still changes hands");
+ const paid = (state.getRegionCash(receiverCity).get(code) || 0) - before;
+ const barePrice = (bought * state.getResourcePrice("food")) / vSeller;
+ this.assertGreater(paid, barePrice, "the contested surplus fetched a premium");
+ }
+
+ test_the_snapshot_ships_the_money_view() {
+ const state = smallState();
+ const stats = state.snapshot(0).viewerStats;
+ this.assertTrue(stats.currency && stats.currency.code, "the viewer's currency travels");
+ this.assertSize(stats.currencies, state.civilisations.length, "every currency travels");
+ this.assertSize(stats.exchangeRates, state.civilisations.length, "every rate travels");
+ this.assertTrue(stats.centralBank, "the central bank travels");
+ this.assertApprox(stats.centralBank.rate, MONEY.defaultInterestRate, 1e-9);
+ this.assertGreater(stats.privateCash, 0, "the private-sector total travels");
+ const city = state.snapshot(0).cityStats[0];
+ this.assertNotNull(city.cash, "a region's cash basket travels");
+ this.assertGreater(city.cashValue, 0, "and its value");
+ }
+}
diff --git a/tests/politics_test.js b/tests/politics_test.js
index 031cfa9..6fa54f3 100644
--- a/tests/politics_test.js
+++ b/tests/politics_test.js
@@ -365,6 +365,14 @@ export class PoliticsModelTest extends TestCase {
test_cultural_impact_is_zero_sum_and_wins_friends() {
const state = smallState();
+ // Funded treasuries and private-sector cash, so the test measures culture
+ // alone: a nation short of either runs a resource shortage, whose popularity
+ // hit drowns out the museum.
+ state.budgets.set(0, 1e15);
+ state.budgets.set(1, 1e15);
+ for (const city of state.cities) {
+ state.getRegionCash(city).set(state.currencyOf(city.civ).code, 1e15);
+ }
const museum = BUILDINGS.findIndex((b) => b.id === "museum");
const city = state.cities.find((c) => c.civ === 0);
city.buildings[museum] = 1;
diff --git a/tests/regression_test.js b/tests/regression_test.js
index 7d20e2e..9d561b4 100644
--- a/tests/regression_test.js
+++ b/tests/regression_test.js
@@ -138,13 +138,13 @@ export class EconomyOrderEdgesTest extends TestCase {
test_battle_triples_unit_upkeep() {
const state = smallState();
const pair = boundaryLand(state);
- const before = state.getPlayerUpkeep(0);
+ const before = state.getCivUpkeepResources(0).steel;
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
// The two hostile units are only "in battle" once they share a tile.
state.units[state.units.length - 1].coords = pair.a;
- const after = state.getPlayerUpkeep(0);
- this.assertGreater(after, before, "a unit in battle costs more to keep");
+ const after = state.getCivUpkeepResources(0).steel;
+ this.assertGreater(after, before, "a unit in battle burns more steel");
}
test_buildings_in_the_catalogue_have_a_cost_curve() {
@@ -208,7 +208,7 @@ export class CatalogueSplitTest extends TestCase {
test_pillaging_with_no_units_changes_nothing() {
const result = pillageSteps(5000, 0);
this.assertEmpty(result.steps);
- this.assertEqual(result.gdpMultiplier, 1);
+ this.assertEqual(result.productionMultiplier, 1);
}
test_effect_value_is_zero_for_a_non_positive_level() {
diff --git a/tests/resources_test.js b/tests/resources_test.js
new file mode 100644
index 0000000..0b08a03
--- /dev/null
+++ b/tests/resources_test.js
@@ -0,0 +1,681 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState, cityOf, grantBuilding } from "./framework/helpers.js";
+import { key } from "../shared/hex.js";
+import {
+ RESOURCE_RULES,
+ RESOURCE_MARKET_BASE,
+ RESOURCE_BUILDINGS,
+ EFFECT_RESEARCH,
+ technologyCost,
+} from "../shared/data.js";
+import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
+import {
+ formatEnergy,
+ formatResourceAmount,
+ foodNeedPerDay,
+ steelNeedPerDay,
+ luxuryNeedPerDay,
+ foodMonthlyTarget,
+ cityEnergyPerDay,
+ deliveryEnergyKwh,
+ deliveryEnergyForResource,
+ nextMarketPrice,
+} from "../shared/resources.js";
+
+function freeLandTile(state, civ = 0) {
+ for (const coords of state._territoryByCiv.get(civ) || []) {
+ if (state.cityAt(coords)) continue;
+ if (state.tileImprovements.has(key(coords.x, coords.y))) continue;
+ if (state._isLand(coords)) return coords;
+ }
+ return null;
+}
+
+function placeResourceBuilding(state, coords, id, civ = 0) {
+ const k = key(coords.x, coords.y);
+ state.tileImprovements.set(k, id);
+ state.tileImprovementOwner.set(k, civ);
+ state.tileImprovementHp.set(k, 1000);
+ state._ensureResourceStock(k);
+ return k;
+}
+
+export class ResourceRulesTest extends TestCase {
+ test_energy_formats_in_friendly_units() {
+ this.assertEqual(formatEnergy(500), "500 kWh");
+ this.assertTrue(formatEnergy(1_500).includes("MWh"));
+ this.assertTrue(formatEnergy(2_500_000).includes("GWh"));
+ this.assertTrue(formatEnergy(3_000_000_000).includes("TWh"));
+ }
+
+ test_material_units_step_up() {
+ this.assertEqual(formatResourceAmount("steel", 250), "250 t");
+ this.assertTrue(formatResourceAmount("steel", 2_500_000).includes("kt"));
+ this.assertTrue(formatResourceAmount("steel", 2_500_000_000).includes("Mt"));
+ this.assertTrue(formatResourceAmount("luxury", 2500).includes("ct"));
+ }
+
+ test_per_person_needs_follow_the_rules() {
+ // 1 kg a day of food and steel, 0.01 ct of luxury, for 2 million people.
+ this.assertApprox(foodNeedPerDay(2_000_000), 2000);
+ this.assertApprox(steelNeedPerDay(2_000_000), 2000);
+ this.assertApprox(luxuryNeedPerDay(2_000_000), 20_000);
+ }
+
+ test_food_target_is_a_month_with_a_cushion() {
+ const need = foodNeedPerDay(2_000_000) * 30 * RESOURCE_RULES.foodMonthlyBuffer;
+ this.assertApprox(foodMonthlyTarget(2_000_000), need);
+ }
+
+ test_city_energy_scales_with_gdp() {
+ this.assertApprox(cityEnergyPerDay(0), 0);
+ this.assertGreater(cityEnergyPerDay(1e11), 0);
+ }
+
+ test_delivery_energy_matches_the_roadmap_figures() {
+ // 1 tonne, 1 tile: 10 MWh by road, 1 MWh by train, 50 kWh by ship.
+ this.assertApprox(deliveryEnergyKwh(1, 1, "road"), 10_000);
+ this.assertApprox(deliveryEnergyKwh(1, 1, "train"), 1_000);
+ this.assertApprox(deliveryEnergyKwh(1, 1, "ship"), 50);
+ // Off-road trucks burn 50 MWh a tile, times the tile's movement cost.
+ this.assertApprox(deliveryEnergyKwh(1, 1, "truck", 4), 200_000);
+ }
+
+ test_energy_delivery_counts_ten_gwh_per_tonne() {
+ // Delivering 10 GWh of energy is one tonne, so its transport is the same
+ // as moving one tonne of steel the same distance.
+ const asEnergy = deliveryEnergyForResource("energy", 10_000_000, 1, "road");
+ const asSteel = deliveryEnergyForResource("steel", 1, 1, "road");
+ this.assertApprox(asEnergy, asSteel);
+ }
+
+ test_market_price_rises_on_shortage_and_falls_on_glut() {
+ const base = RESOURCE_MARKET_BASE.steel;
+ const scarce = nextMarketPrice(base, base, 10, 20);
+ const glut = nextMarketPrice(base, base, 20, 10);
+ this.assertGreater(scarce, base);
+ this.assertLess(glut, base);
+ }
+}
+
+export class ResourceModelTest extends TestCase {
+ test_cities_start_with_a_food_store() {
+ const state = smallState();
+ for (const city of state.cities) {
+ const stock = state.getCityResourceStock(city);
+ this.assertGreater(stock.food, 0, "the city opens with food");
+ this.assertGreater(stock.steel, 0, "the city opens with steel");
+ }
+ }
+
+ test_construction_resource_cost_follows_the_money_cost() {
+ const state = smallState();
+ const cost = state.constructionResourceCost({ buildCost: 1_000_000, effects: [] });
+ this.assertApprox(cost.steel, 1_000_000 * RESOURCE_RULES.steelPerBudgetEuro);
+ this.assertEqual(cost.hightech, 0);
+ const lab = state.protoBuildings.find((b) => b.id === "research_lab");
+ this.assertGreater(state.constructionResourceCost(lab, 0, lab.baseCost).hightech, 0);
+ }
+
+ test_resource_money_tracks_buying_and_selling() {
+ const state = smallState();
+ state._recordResourceSpend(0, "goods", 1000, "steel");
+ state._recordResourceEarn(0, "steel", 400);
+ const breakdown = state.budgetBreakdown(0);
+ const steel = breakdown.resourceMoney.find((entry) => entry.id === "steel");
+ this.assertNotNull(steel, "steel appears in the trade breakdown");
+ this.assertApprox(steel.bought, 1000);
+ this.assertApprox(steel.sold, 400);
+ }
+
+ test_national_gdp_per_capita_matches_the_regions() {
+ const state = smallState();
+ const snapshot = state.snapshot(0);
+ let gdp = 0;
+ let population = 0;
+ for (const city of state.cities) {
+ if (city.civ !== 0) continue;
+ const stats = snapshot.cityStats.find((entry) => entry.id === city.id);
+ gdp += stats.gdp;
+ population += stats.population;
+ }
+ this.assertApprox(snapshot.viewerStats.gdpPerCapita, gdp / population, 1e-6);
+ }
+
+ test_construction_draws_steel_from_the_city_before_the_market() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const stock = state.getCityResourceStock(city);
+ stock.steel = 1000;
+ const before = state.getBudget(0);
+ const ok = state._payConstructionResources(0, city.coords, { steel: 400, hightech: 0 });
+ this.assertTrue(ok);
+ this.assertApprox(stock.steel, 600, 1e-6, "steel came out of the store");
+ this.assertApprox(state.getBudget(0), before, 1e-6, "no market bill was needed");
+ }
+
+ test_construction_buys_the_shortfall_on_the_market() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ // Every city of the nation must be empty, or the draw would find another
+ // store before it reaches the market.
+ for (const own of state.cities) {
+ if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
+ }
+ const price = state.getResourcePrice("steel");
+ const before = state.getBudget(0);
+ const ok = state._payConstructionResources(0, city.coords, { steel: 1000, hightech: 0 });
+ this.assertTrue(ok);
+ this.assertApprox(before - state.getBudget(0), 1000 * price, 1e-3);
+ }
+
+ test_research_spends_high_tech() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ grantBuilding(state, city, "research_lab");
+ const tech = state.technologies.findIndex((t) => t.id === "scientific_method");
+ this.assertTrue(state.requestFocusTechnology(0, tech));
+ const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
+ // With a store of high-tech the lab's one point an hour comes through.
+ for (const own of state.cities) {
+ if (own.civ === 0) state.getCityResourceStock(own).hightech = 1000;
+ }
+ const before = state.getTechnologyProgress(0, tech);
+ state.advanceHour();
+ const withTech = state.getTechnologyProgress(0, tech) - before;
+ this.assertApprox(withTech, governmentRate + 1.0, 1e-6);
+ // With no store, the lab's point is simply not produced.
+ for (const own of state.cities) {
+ if (own.civ === 0) state.getCityResourceStock(own).hightech = 0;
+ }
+ const before2 = state.getTechnologyProgress(0, tech);
+ state.advanceHour();
+ const withoutTech = state.getTechnologyProgress(0, tech) - before2;
+ this.assertApprox(withoutTech, governmentRate, 1e-6);
+ }
+
+ test_a_power_plant_supplies_its_grid() {
+ const withPlant = smallState();
+ const tile = freeLandTile(withPlant, 0);
+ placeResourceBuilding(withPlant, tile, "coal_power_plant", 0);
+ withPlant._tickResources();
+ const stats = withPlant.resourceMarketStats.get("energy");
+ this.assertGreater(stats.supply, 0, "the plant fed the grid");
+ }
+
+ test_gdp_is_the_market_value_of_what_a_region_produces() {
+ const state = smallState();
+ const tile = freeLandTile(state, 0);
+ const region = state.regionCityAt(tile);
+ this.assertNotNull(region, "the tile belongs to a region");
+ const before = state._regionProductionValue(region);
+ placeResourceBuilding(state, tile, "steel_mill", 0);
+ const after = state._regionProductionValue(region);
+ const proto = RESOURCE_BUILDINGS.find((b) => b.id === "steel_mill");
+ const outputValue = proto.outputPerDay * state.getResourcePrice("steel") * DAYS_PER_YEAR;
+ this.assertApprox(after - before, outputValue, outputValue * 1e-6, "the mill's output is GDP");
+ }
+
+ test_a_steel_mill_consumes_energy_and_makes_steel() {
+ const state = smallState();
+ const plantTile = freeLandTile(state, 0);
+ placeResourceBuilding(state, plantTile, "coal_power_plant", 0);
+ // A second owned tile for the mill, away from the city.
+ const millTile = state._territoryByCiv.get(0).find(
+ (coords) => state._isLand(coords) && !state.cityAt(coords) &&
+ key(coords.x, coords.y) !== key(plantTile.x, plantTile.y)
+ );
+ const k = placeResourceBuilding(state, millTile, "steel_mill", 0);
+ state._tickResources();
+ const stock = state._ensureResourceStock(k);
+ this.assertGreater(stock.steel, 0, "the mill produced steel");
+ }
+
+ test_a_city_buys_from_a_neighbour_before_the_market() {
+ const state = smallState();
+ const cities = state.cities.filter((c) => c.civ === 0);
+ this.assertGreaterOrEqual(cities.length, 2, "the fixture has two cities");
+ const buyer = cities[0];
+ const seller = cities[1];
+ state.getCityResourceStock(seller).food = 1e9;
+ state.getCityResourceStock(buyer).food = 0;
+ const store = state.getCityResourceStock(buyer);
+ const bought = state._procureFromNeighbours(
+ buyer, "food", 1000, store, state._resourceNodes()
+ );
+ this.assertGreater(bought, 0, "the neighbour sold food");
+ this.assertGreater(store.food, 0, "the buyer received it");
+ this.assertTrue(
+ state.resourceLinks.some((link) => link.resource === "food"),
+ "the delivery is on the graph"
+ );
+ }
+
+ test_foreign_trade_runs_on_the_transport_network() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const buyer = cityOf(state, 0);
+ const seller = cityOf(state, 1);
+ state.getCityResourceStock(seller).food = 1e9;
+ state.getCityResourceStock(buyer).food = 0;
+ const store = state.getCityResourceStock(buyer);
+ const boughtOnNetwork = state._procureFromNeighbours(
+ buyer, "food", 1000, store, state._resourceNodes()
+ );
+ this.assertGreater(boughtOnNetwork, 0, "a foreign market on the network is reachable");
+
+ // Tear up every road and railway: the foreign market is now unreachable.
+ state.roads.clear();
+ state.railways.clear();
+ store.food = 0;
+ state.resourceLinks = [];
+ const boughtOffNetwork = state._procureFromNeighbours(
+ buyer, "food", 1000, store, state._resourceNodes()
+ );
+ this.assertEqual(boughtOffNetwork, 0, "no road, no foreign market");
+ }
+
+ test_cross_nation_deliveries_are_booked_as_imports_and_exports() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const buyer = cityOf(state, 0);
+ const seller = cityOf(state, 1);
+ state.getCityResourceStock(seller).food = 1e9;
+ state.getCityResourceStock(buyer).food = 0;
+ const bought = state._procureFromNeighbours(
+ buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes()
+ );
+ this.assertGreater(bought, 0, "the neighbour sold food");
+ this.assertApprox(state._civResourceTrade(0).imports.food, bought, 1e-6, "the buyer imported it");
+ this.assertApprox(state._civResourceTrade(1).exports.food, bought, 1e-6, "the seller exported it");
+ // The snapshot carries every nation's trade ledger, so the panel can rank
+ // the world's major exporters and importers.
+ const snapshot = state.snapshot(0);
+ this.assertSize(snapshot.resourceTrade, 2);
+ this.assertApprox(snapshot.resourceTrade[1].exports.food, bought, 1e-3);
+ }
+
+ test_a_shortage_costs_popularity() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const before = state.getCivPopularity(0);
+ state._resourceShortages.set(city.id, { steel: 1 });
+ state._applyResourceShortages(new Map());
+ this.assertLess(state.getCivPopularity(0), before, "a shortage hurt the government");
+ }
+
+ test_a_resource_building_cannot_stand_inside_a_city() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ this.assertFalse(
+ state.requestBuildTileImprovement(0, city.coords, "coal_power_plant"),
+ "a power plant belongs on open land"
+ );
+ const tile = freeLandTile(state, 0);
+ state.budgets.set(0, 1e15);
+ this.assertTrue(
+ state.requestBuildTileImprovement(0, tile, "coal_power_plant"),
+ "and is accepted on a controlled land tile"
+ );
+ }
+
+ test_snapshot_ships_the_market_and_the_delivery_graph() {
+ const state = smallState();
+ const snapshot = state.snapshot(0);
+ this.assertSize(snapshot.resourceMarket, 5);
+ for (const entry of snapshot.resourceMarket) {
+ this.assertHas(entry, "price");
+ this.assertGreater(entry.price, 0);
+ }
+ this.assertHas(snapshot.viewerStats, "resources");
+ this.assertHas(snapshot.resourceGraph, "nodes");
+ this.assertHas(snapshot.resourceGraph, "links");
+ }
+
+ // ------------------------------------------------- starting industry ----
+
+ test_the_opening_economy_is_seeded_with_covering_industry() {
+ const state = smallState();
+ this.assertGreater(state.tileImprovements.size, 0, "the world opens with resource works");
+ const components = state._resourceLandComponents();
+ const foodEnergy = state._foodEnergyByComponent(components);
+ for (let civ = 0; civ < state.civilisations.length; civ++) {
+ const deficits = state._resourceSeedDeficits(civ);
+ this.assertTrue(deficits.steel <= 0, "steel production covers the people and the works");
+ this.assertTrue(deficits.luxury <= 0, "luxury production covers the people");
+ const energy = state._resourceSeedEnergy(civ, components, foodEnergy);
+ this.assertEmpty(energy, "every grid has the power its cities and industry draw");
+ }
+ }
+
+ test_seeded_industry_is_connected_to_the_transport_network() {
+ const state = smallState();
+ this.assertGreater(state.tileImprovements.size, 0, "there are seeded works");
+ for (const k of state.tileImprovements.keys()) {
+ this.assertTrue(
+ state.roads.has(k) || state.railways.has(k),
+ "a seeded work sits on the transport network"
+ );
+ }
+ }
+
+ test_seeding_is_deterministic_from_the_world_seed() {
+ const first = smallState(["france", "britain"], 12345);
+ const again = smallState(["france", "britain"], 12345);
+ const other = smallState(["france", "britain"], 999);
+ const describe = (state) =>
+ Array.from(state.tileImprovements.entries())
+ .map(([k, id]) => `${k}:${id}`)
+ .sort()
+ .join("|");
+ this.assertEqual(describe(first), describe(again), "the same seed lays out the same works");
+ this.assertNotEqual(describe(first), describe(other), "a different seed lays out different works");
+ }
+
+ // ------------------------------------------------------- upkeep -------
+
+ test_building_upkeep_is_materials_per_level_per_day() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ const before = state.getBuildingUpkeep(0);
+ const index = grantBuilding(state, city, "research_lab", 1);
+ const after = state.getBuildingUpkeep(0);
+ // A 100 M lab: 50 t of steel, 100 MWh of energy and 200 high-tech a day.
+ this.assertApprox(after.steel - before.steel, 50, 1e-6);
+ this.assertApprox(after.hightech - before.hightech, 200, 1e-6);
+ const beforeLevel = after.steel;
+ city.buildings[index] = 2;
+ this.assertGreater(state.getBuildingUpkeep(0).steel, beforeLevel, "a higher level wears more");
+ }
+
+ test_the_state_pays_upkeep_materials_from_the_budget() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ grantBuilding(state, city, "market", 4);
+ // Drain the stores so the day's upkeep has to be bought.
+ for (const own of state.cities) {
+ if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
+ }
+ state.advanceHour();
+ // The material bill is settled on the daily tick, not hourly.
+ for (let i = 0; i < 23; i++) state.advanceHour();
+ const spend = state.resourceSpend.get(0) || {};
+ this.assertGreater(spend.upkeep || 0, 0, "the spend is booked as upkeep");
+ // The materials bill lands in the month's budget under an upkeep category.
+ const report = state.budgetMonthReports(0).at(-1);
+ const upkeep = report.expenses.filter(
+ (entry) => ["buildings", "units", "works", "transport"].includes(entry.id)
+ );
+ this.assertGreater(upkeep.length, 0, "the treasury paid it, filed as upkeep");
+ }
+
+ test_unaffordable_upkeep_costs_popularity() {
+ const state = smallState();
+ const before = state.getCivPopularity(0);
+ state._civilianUpkeepShortage(0, 1);
+ this.assertLess(state.getCivPopularity(0), before, "an unpaid bill is unrest");
+ }
+
+ // ------------------------------------------------------- repairs ------
+
+ test_a_razed_building_level_is_rebuilt_with_materials() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ const index = grantBuilding(state, city, "market", 1);
+ city.buildings[index] = 0;
+ state._queueCityRepair(city, index, 1);
+ state._tickRepairs();
+ this.assertEqual(city.buildings[index], 1, "the level came back once its materials were paid");
+ }
+
+ test_a_damaged_tile_work_heals_once_its_debt_is_paid() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const tile = freeLandTile(state, 0);
+ const k = placeResourceBuilding(state, tile, "fortifications", 0);
+ state.tileImprovementHp.set(k, 200);
+ state._recordRepairDebt(tile, 200);
+ const debt = state.tileRepairDebt.get(k);
+ this.assertGreater(debt, 0, "damage left a debt");
+ for (let i = 0; i < 20; i++) state._tickRepairs();
+ this.assertFalse(state.tileRepairDebt.has(k), "the debt was paid off");
+ this.assertGreater(state.tileImprovementHp.get(k), 200, "the work healed");
+ }
+
+ test_a_damaged_unit_heals_at_a_materials_cost() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
+ unit.hp = 100;
+ // Empty the stores so the heal must be bought.
+ for (const own of state.cities) {
+ if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
+ }
+ const before = state.getBudget(0);
+ state._tickGarrisonHealing();
+ this.assertGreater(unit.hp, 100, "the unit healed");
+ this.assertLess(state.getBudget(0), before, "healing cost the treasury");
+ }
+
+ test_a_tile_with_repair_debt_does_not_recover_its_gdp() {
+ const state = smallState();
+ const tile = freeLandTile(state, 0);
+ const k = key(tile.x, tile.y);
+ state.tileBattleProductionDeficit.set(k, 1000);
+ state.tileBattleRecoveryStart.set(k, 0);
+ state._recordRepairDebt(tile, 100);
+ state._tickProductionRecovery();
+ this.assertApprox(state.tileBattleProductionDeficit.get(k), 1000, 1e-6, "the scar waits for its materials");
+ }
+
+ // ------------------------------------------------ tech gate / combat --
+
+ test_fusion_needs_its_technology() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const tile = freeLandTile(state, 0);
+ this.assertFalse(
+ state.requestBuildTileImprovement(0, tile, "fusion_power_plant"),
+ "fusion is locked without the technology"
+ );
+ const index = state.technologies.findIndex((t) => t.id === "fusion_power");
+ this.assertGreater(index, 0, "the fusion technology exists");
+ this.assertEqual(technologyCost(state.technologies[index]), 500_000, "it costs half a million points");
+ state.researched.get(0).add(index);
+ state.budgets.set(0, 1e15);
+ this.assertTrue(
+ state.requestBuildTileImprovement(0, tile, "fusion_power_plant"),
+ "and unlocks the plant once researched"
+ );
+ }
+
+ test_infantry_food_multiplies_in_combat_and_when_resting() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
+ const unit = state._spawnUnit(city.coords, 0, proto);
+ const base = foodNeedPerDay(proto.population);
+ this.assertApprox(state._unitFoodRate(unit, proto, false), base, 1e-9, "idle rations");
+ this.assertApprox(
+ state._unitFoodRate(unit, proto, true),
+ base * RESOURCE_RULES.infantryCombatFoodMultiplier,
+ 1e-9,
+ "combat rations"
+ );
+ unit.hp = 100;
+ this.assertApprox(
+ state._unitFoodRate(unit, proto, false),
+ base * RESOURCE_RULES.infantryRestingFoodMultiplier,
+ 1e-9,
+ "wounded and resting rations"
+ );
+ }
+
+ test_a_land_unit_carries_fifty_days_of_food() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
+ const unit = state._spawnUnit(city.coords, 0, proto);
+ this.assertEqual(unit.foodDays, RESOURCE_RULES.unitSupplyDays, "a full pack");
+ }
+
+ test_a_supplied_land_unit_tops_up_its_pack() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
+ const unit = state._spawnUnit(city.coords, 0, proto);
+ unit.foodDays = 10;
+ state._tickUnitSupply();
+ this.assertEqual(unit.foodDays, RESOURCE_RULES.unitSupplyDays, "it is fed at home");
+ }
+
+ test_a_cut_off_land_unit_starves() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
+ const unit = state._spawnUnit(city.coords, 0, proto);
+ // No reachable allied city, and an empty pack: the soldiers start dying.
+ state._unitSupplyCity = () => null;
+ unit.foodDays = 0;
+ const before = unit.hp;
+ state._tickUnitSupply();
+ this.assertLess(unit.hp, before, "starvation costs hit points");
+ }
+
+ test_air_and_naval_units_do_not_carry_supply() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const jet = state.protoUnits.find((p) => p.id === "jet_fighter");
+ const unit = state._spawnUnit(city.coords, 0, jet, city.id);
+ unit.foodDays = 5;
+ state._unitSupplyCity = () => null;
+ state._tickUnitSupply();
+ this.assertEqual(unit.foodDays, 5, "an aircraft uses endurance, not a food pack");
+ }
+
+ test_an_encircled_city_cannot_buy_from_the_market() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ // Ring the city with enemies and empty its stores.
+ for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
+ if (state._isLand(neighbour)) state._spawnUnit(neighbour, 1, state.protoUnits[0]);
+ }
+ const store = state.getCityResourceStock(city);
+ store.food = 0;
+ store.steel = 0;
+ store.luxury = 0;
+ const market = new Map(
+ ["food", "steel", "luxury", "energy", "hightech"].map((id) => [id, { demand: 0 }])
+ );
+ state._consumeCityResources(new Map(), market);
+ this.assertEqual(store.food, 0, "no food came in through the ring");
+ this.assertGreater(state._resourceShortages.size, 0, "the shortage is recorded");
+ }
+
+ test_an_encircled_unit_cannot_resupply() {
+ const state = smallState();
+ const unit = state.units[0];
+ for (const neighbour of state.topology.neighbours(unit.coords.x, unit.coords.y)) {
+ if (state._isLand(neighbour)) state._spawnUnit(neighbour, 1, state.protoUnits[0]);
+ }
+ unit.foodDays = 10;
+ state._tickUnitSupply();
+ this.assertLess(unit.foodDays, 10, "a cut-off unit eats its reserves");
+ }
+
+ test_a_unit_off_the_network_starves() {
+ const state = smallState();
+ const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
+ const tile = freeLandTile(state, 0);
+ const unit = state._spawnUnit(tile, 0, proto);
+ // Tear up the network: the unit is cut off and eats its reserves.
+ state.roads.clear();
+ state.railways.clear();
+ unit.foodDays = 10;
+ state._tickUnitSupply();
+ this.assertLess(unit.foodDays, 10, "no road, no resupply");
+ }
+
+ test_naval_cities_start_with_a_port() {
+ const state = smallState();
+ const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
+ const inland = state.cities.find((c) => c.civ === 0 && !state.isCoastalCity(c));
+ this.assertNotNull(coastal, "the nation has a naval city");
+ this.assertTrue(state.hasCityBuilding(coastal, "port"), "a naval city starts with a port");
+ this.assertFalse(inland && state.hasCityBuilding(inland, "port"), "an inland city does not");
+ }
+
+ test_a_city_without_a_port_has_no_foreign_market() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ const store = state.getCityResourceStock(city);
+ const portIndex = state.protoBuildings.findIndex((b) => b.id === "port");
+ for (const own of state.cities) {
+ if (own.civ === 0) delete own.buildings[portIndex];
+ }
+ this.assertFalse(state._civMarketAccess(0), "no working port, no market");
+ this.assertEqual(state._buyMarketMaterial(city, "food", 100, store), 0, "nothing can be bought");
+
+ const coastal = state.cities.find((c) => c.civ === 0 && state.isCoastalCity(c));
+ coastal.buildings[portIndex] = 1;
+ this.assertTrue(state._civMarketAccess(0), "the port opens the market");
+ this.assertGreater(state._buyMarketMaterial(city, "food", 100, store), 0, "goods now arrive");
+ }
+
+ test_air_defense_spends_ammunition_when_it_fires() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ grantBuilding(state, city, "aa_building");
+ for (const own of state.cities) {
+ if (own.civ === 0) {
+ const store = state.getCityResourceStock(own);
+ store.steel = 0;
+ store.hightech = 0;
+ }
+ }
+ const plane = state.protoUnits.find((p) => p.id === "jet_fighter");
+ const intruder = state._spawnUnit(city.coords, 1, plane);
+ state._airDefenseVolley(intruder);
+ const spend = state.resourceSpend.get(0) || {};
+ this.assertGreater(spend.combat || 0, 0, "firing the air defense cost materials");
+ }
+
+ test_training_a_unit_draws_its_soldiers_from_the_region() {
+ const state = smallState();
+ const city = cityOf(state, 0);
+ const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
+ const before = state.regionPopulation(city);
+ state._spawnTrainedUnit(city, proto);
+ const after = state.regionPopulation(city);
+ this.assertApprox(before - after, proto.population, 1e-6, "1000 people became soldiers");
+ }
+
+ test_healing_a_unit_draws_replacement_soldiers() {
+ const state = smallState();
+ state.budgets.set(0, 1e15);
+ const city = cityOf(state, 0);
+ const proto = state.protoUnits.find((p) => p.id === "modern_infantry");
+ const unit = state._spawnUnit(city.coords, 0, proto);
+ unit.hp = 100;
+ const before = state.regionPopulation(city);
+ state._tickGarrisonHealing();
+ const gained = unit.hp - 100;
+ this.assertGreater(gained, 0, "the unit healed");
+ // Each hit point is a share of the unit's soldiers, drawn from the people.
+ this.assertApprox(
+ before - state.regionPopulation(city),
+ (gained / unit.maxHp) * proto.population,
+ 1e-6
+ );
+ }
+}
diff --git a/tests/rules_test.js b/tests/rules_test.js
index dce9eef..95b9d71 100644
--- a/tests/rules_test.js
+++ b/tests/rules_test.js
@@ -11,15 +11,15 @@ import {
defenseFactors,
defenseMultiplier,
unitDefense,
- gdpPerCapitaFactors,
- gdpPerCapitaFromFactors,
+ productionFactors,
+ productionFromFactors,
} from "../shared/rules.js";
import {
effectValue,
GOVERNMENTS,
TECHNOLOGIES,
TERRAIN_TILES,
- EFFECT_GDP,
+ EFFECT_PRODUCTION,
EFFECT_RESEARCH,
EFFECT_CULTURE,
} from "../shared/data.js";
@@ -75,15 +75,15 @@ export class RulesTest extends TestCase {
test_building_effect_value_sums_matching_stats() {
const building = proto({
effects: [
- { stat: "gdp", baseValue: 0.04, exponent: 0.75 },
- { stat: "gdp", baseValue: 0.01, exponent: 1.0 },
+ { stat: "production", baseValue: 0.04, exponent: 0.75 },
+ { stat: "production", baseValue: 0.01, exponent: 1.0 },
{ stat: "culture", baseValue: 1.0, exponent: 1.0 },
],
});
- this.assertApprox(buildingEffectValue(building, "gdp", 1), 0.05);
+ this.assertApprox(buildingEffectValue(building, "production", 1), 0.05);
this.assertApprox(buildingEffectValue(building, "culture", 1), 1.0);
this.assertEqual(buildingEffectValue(building, "research", 1), 0);
- this.assertEqual(buildingEffectValue(building, "gdp", 0), 0);
+ this.assertEqual(buildingEffectValue(building, "production", 0), 0);
}
test_government_change_cost() {
@@ -95,7 +95,7 @@ export class RulesTest extends TestCase {
test_catalogue_effects() {
const monarchy = GOVERNMENTS.find((g) => g.id === "monarchy");
- this.assertApprox(governmentEffects(monarchy)[EFFECT_GDP], 0.1);
+ this.assertApprox(governmentEffects(monarchy)[EFFECT_PRODUCTION], 0.1);
this.assertApprox(governmentEffects(monarchy)[EFFECT_CULTURE], 1.0);
const technocracy = GOVERNMENTS.find((g) => g.id === "technocracy");
this.assertGreater(
@@ -104,7 +104,7 @@ export class RulesTest extends TestCase {
);
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method");
this.assertApprox(technologyEffects(method)[EFFECT_RESEARCH], 1.0);
- this.assertApprox(technologyEffects(method)[EFFECT_GDP], 0.02);
+ this.assertApprox(technologyEffects(method)[EFFECT_PRODUCTION], 0.02);
}
test_prerequisites_met() {
@@ -174,40 +174,40 @@ export class RulesTest extends TestCase {
this.assertApprox(bonusOf("Land"), 0);
}
- test_gdp_per_capita_factors_name_the_terrain_and_settlement() {
+ test_production_factors_name_the_terrain_and_settlement() {
const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land");
- const base = gdpPerCapitaFactors(plains);
+ const base = productionFactors(plains);
this.assertSize(base, 1);
this.assertEqual(base[0].label, "Land");
this.assertApprox(base[0].multiplier, 1);
- const city = gdpPerCapitaFactors(plains, { cityKind: "city" });
+ const city = productionFactors(plains, { cityKind: "city" });
this.assertSize(city, 2);
this.assertEqual(city[1].label, "City");
this.assertApprox(city[1].multiplier, 2);
- const capital = gdpPerCapitaFactors(plains, { cityKind: "capital" });
+ const capital = productionFactors(plains, { cityKind: "capital" });
this.assertApprox(capital[1].multiplier, 2.5);
- const near = gdpPerCapitaFactors(plains, { nextToCity: true });
+ const near = productionFactors(plains, { nextToCity: true });
this.assertEqual(near[1].label, "Near city");
this.assertApprox(near[1].multiplier, 1.3);
}
- test_gdp_per_capita_factors_include_the_marks_of_war() {
+ test_production_factors_include_the_marks_of_war() {
const plains = TERRAIN_TILES.find((t) => t.terrainType === "Land");
- const factors = gdpPerCapitaFactors(plains, { pillagePenalty: 0.7, battleDeficit: 500 });
+ const factors = productionFactors(plains, { pillagePenalty: 0.7, battleDeficit: 500 });
this.assertEqual(factors[1].label, "Pillaging");
this.assertApprox(factors[1].multiplier, 0.7);
this.assertEqual(factors[2].label, "Battle damage");
this.assertApprox(factors[2].amount, -500);
}
- test_gdp_per_capita_from_factors_applies_in_order_and_floors_at_zero() {
+ test_production_from_factors_applies_in_order_and_floors_at_zero() {
this.assertApprox(
- gdpPerCapitaFromFactors(10000, [{ multiplier: 2 }, { amount: -500 }]),
+ productionFromFactors(10000, [{ multiplier: 2 }, { amount: -500 }]),
19500
);
- this.assertEqual(gdpPerCapitaFromFactors(100, [{ amount: -500 }]), 0);
+ this.assertEqual(productionFromFactors(100, [{ amount: -500 }]), 0);
}
}
diff --git a/tests/siege_test.js b/tests/siege_test.js
index 6641ac4..46b2d12 100644
--- a/tests/siege_test.js
+++ b/tests/siege_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
-import { smallState, mapLayers, SEED } from "./framework/helpers.js";
-import { parseKey } from "../shared/hex.js";
+import { smallState, mapLayers, SEED, grantBuilding } from "./framework/helpers.js";
+import { parseKey, key } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
import { MapView } from "../client/js/map_view.js";
@@ -28,44 +28,18 @@ export class SiegeTest extends TestCase {
}
}
- // The supply check only runs when a hostile is adjacent: the daily refresh
- // never calls into the search otherwise.
- test_the_check_only_triggers_with_an_adjacent_hostile() {
- const state = smallState();
- const unit = state.units[0];
- const neighbours = state.topology.neighbours(unit.coords.x, unit.coords.y).filter(
- (n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
- );
- this.assertGreaterOrEqual(neighbours.length, 2, "the capital has open land around it");
-
- let calls = 0;
- const original = state._unitStatuses.bind(state);
- state._unitStatuses = (...args) => {
- calls += 1;
- return original(...args);
- };
-
- state._refreshStatuses();
- this.assertEqual(calls, 0, "no hostile, no supply search");
-
- state._spawnUnit(neighbours[0], 0, state.protoUnits[0]);
- state._refreshStatuses();
- this.assertEqual(calls, 0, "a friendly neighbour does not trigger it");
-
- state._spawnUnit(neighbours[1], 1, state.protoUnits[0]);
- state._refreshStatuses();
- this.assertGreater(calls, 0, "a hostile neighbour triggers the check");
- this.assertEmpty(unit.statuses, "the unit is still supplied");
- }
-
- test_a_cut_off_unit_with_a_hostile_neighbour_is_encircled() {
+ test_a_gap_in_the_ring_frees_the_unit() {
const state = smallState();
const tile = findSurroundableTile(state);
- this.assertNotNull(tile, "found an interior tile to surround");
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
- surroundTile(state, tile, 1);
+ const blockers = surroundTile(state, tile, 1);
state._refreshStatuses();
- this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
+ this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "a full ring encircles");
+
+ // Opening one land tile -- an allied unit or an empty tile -- breaks it.
+ state._destroyUnit(blockers[0]);
+ state._refreshStatuses();
+ this.assertEmpty(unit.statuses, "one open land tile frees the unit");
}
test_a_friendly_neighbour_does_not_trigger_encirclement() {
@@ -106,13 +80,13 @@ export class SiegeTest extends TestCase {
this.assertApprox(unit.hp, before - unit.maxHp * 0.02);
}
- test_a_lone_city_can_never_be_besieged() {
+ test_a_lone_city_can_be_besieged() {
const state = smallState(["france"], SEED, { citiesPerCiv: 1 });
const city = state.cities[0];
this.assertNotNull(city);
surroundTile(state, city.coords, 1);
state._refreshStatuses();
- this.assertEmpty(city.statuses, "a city with no sibling is not besieged");
+ this.assertEqual(city.statuses, [STATUS_BESIEGED], "a surrounded portless city is besieged");
}
test_status_is_refreshed_once_a_day_not_every_snapshot() {
@@ -136,35 +110,32 @@ export class SiegeTest extends TestCase {
this.assertEmpty(unit.statuses, "the daily refresh freed the unit");
}
- test_an_allied_naval_unit_lifts_a_siege_across_water() {
+ test_a_port_survives_a_land_ring_and_falls_to_a_blockade() {
const state = smallState();
- const spot = findCoastalPair(state);
- this.assertNotNull(spot, "found a sea tile between two land tiles");
- const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]);
- surroundTile(state, spot.l1, 1);
+ const city = findCoastalClearCity(state, 0);
+ this.assertNotNull(city, "found a coastal city with a clear land ring");
+ grantBuilding(state, city, "port");
+ surroundTile(state, city.coords, 1);
state._refreshStatuses();
- this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "cut off by land");
+ this.assertEmpty(city.statuses, "the port supplies the city by sea");
- state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false });
- state._rebuildCityIndex();
+ // Enemy warships now hold every sea tile around the city.
+ for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
+ const tile = state.tiles[key(neighbour.x, neighbour.y)];
+ if (tile && tile.terrainClass === "Sea") state._spawnUnit(neighbour, 1, navalProto());
+ }
state._refreshStatuses();
- this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "no lane without a ship");
-
- state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 0, navalProto());
- state._refreshStatuses();
- this.assertEmpty(unit.statuses, "the friendly navy reopens the lane");
+ this.assertEqual(city.statuses, [STATUS_BESIEGED], "the blockade cuts the sea lane");
}
- test_an_enemy_naval_unit_does_not_open_a_lane() {
+ test_an_allied_naval_unit_does_not_break_a_land_ring() {
const state = smallState();
- const spot = findCoastalPair(state);
- const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]);
- surroundTile(state, spot.l1, 1);
- state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false });
- state._rebuildCityIndex();
- state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 1, navalProto());
+ const tile = findSurroundableTile(state);
+ const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
+ surroundTile(state, tile, 1);
+ state._spawnUnit(tile, 0, navalProto()); // a friendly hull on the same land tile
state._refreshStatuses();
- this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "an enemy fleet is no supply line");
+ this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "only a port breaks the ring");
}
async test_the_client_draws_status_icons_over_units_and_cities() {
@@ -238,7 +209,22 @@ function surroundTile(state, coords, civ) {
function findBesiegableCity(state, civ) {
for (const city of state.cities) {
if (city.civ !== civ) continue;
- if (state.unitAt(city.coords)) continue;
+ // A coastal city now starts with a port, which keeps it supplied; besiege
+ // an inland city instead.
+ if (state.isCoastalCity(city)) continue;
+ const land = state.topology.neighbours(city.coords.x, city.coords.y).filter((n) => state._isLand(n));
+ if (land.length === 0) continue;
+ if (land.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
+ return city;
+ }
+ return null;
+}
+
+// A coastal city whose land ring is clear, so a port's sea lane can be tested.
+function findCoastalClearCity(state, civ) {
+ for (const city of state.cities) {
+ if (city.civ !== civ) continue;
+ if (!state.isCoastalCity(city)) continue;
const land = state.topology.neighbours(city.coords.x, city.coords.y).filter((n) => state._isLand(n));
if (land.length === 0) continue;
if (land.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
diff --git a/tests/stacking_test.js b/tests/stacking_test.js
index 2634d0f..9d24f3f 100644
--- a/tests/stacking_test.js
+++ b/tests/stacking_test.js
@@ -135,7 +135,7 @@ export class PillageTest extends TestCase {
this.assertTrue(state.requestPillage([unit.id]));
this.assertApprox(state.tilePopulation.get(k), 90000, 1);
- this.assertApprox(state.tileGdpPenalty.get(k), 0.7, 1e-9);
+ this.assertApprox(state.tileProductionPenalty.get(k), 0.7, 1e-9);
this.assertApprox(state.getTileGdpPerCapita(coords), gdpBefore * 0.7, 1e-6);
this.assertApprox(unit.hp, 110, 1e-9, "heals 1 HP per 1000 people removed");
}
@@ -153,7 +153,7 @@ export class PillageTest extends TestCase {
this.assertTrue(state.requestPillage([strong.id, weak.id]));
this.assertApprox(state.tilePopulation.get(k), 81000, 1, "90% of 90% remains");
- this.assertApprox(state.tileGdpPenalty.get(k), 0.49, 1e-9, "70% of 70% remains");
+ this.assertApprox(state.tileProductionPenalty.get(k), 0.49, 1e-9, "70% of 70% remains");
this.assertApprox(weak.hp, strong.maxHp / 2 + 10, 1e-9, "the weakest pillages first");
this.assertApprox(strong.hp, strong.maxHp - 100 + 9, 1e-9, "the second heals for the 9000 it removed");
}
diff --git a/tests/terrain_stats_test.js b/tests/terrain_stats_test.js
index 483f702..4504363 100644
--- a/tests/terrain_stats_test.js
+++ b/tests/terrain_stats_test.js
@@ -5,17 +5,17 @@ import { TERRAIN_TILES } from "../shared/data.js";
export class TerrainStatsTest extends TestCase {
test_authored_values_are_read() {
const stats = new TerrainStats({
- "0,0": { populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5 },
+ "0,0": { populationMultiplier: 0.3, productionMultiplier: 0.5, movementCostMultiplier: 1.5 },
});
this.assertApprox(stats.populationMultiplier({ x: 0, y: 0 }), 0.3);
- this.assertApprox(stats.gdpMultiplier({ x: 0, y: 0 }), 0.5);
+ this.assertApprox(stats.productionMultiplier({ x: 0, y: 0 }), 0.5);
this.assertApprox(stats.movementCostMultiplier({ x: 0, y: 0 }), 1.5);
}
test_unset_values_fall_back() {
const stats = new TerrainStats({ "0,0": {} });
this.assertApprox(stats.populationMultiplier({ x: 0, y: 0 }), 0.0);
- this.assertApprox(stats.gdpMultiplier({ x: 0, y: 0 }), 0.0);
+ this.assertApprox(stats.productionMultiplier({ x: 0, y: 0 }), 0.0);
this.assertApprox(stats.movementCostMultiplier({ x: 0, y: 0 }), DEFAULT_MOVEMENT_COST_MULTIPLIER);
}
@@ -48,7 +48,7 @@ export class TerrainStatsTest extends TestCase {
this.assertNotEmpty(land);
for (const tile of land) {
this.assertHas(tile, "populationMultiplier");
- this.assertHas(tile, "gdpMultiplier");
+ this.assertHas(tile, "productionMultiplier");
this.assertHas(tile, "movementCostMultiplier");
}
const cheapest = Math.min(...TERRAIN_TILES.map((t) => t.movementCostMultiplier || DEFAULT_MOVEMENT_COST_MULTIPLIER));
diff --git a/tests/warfare_test.js b/tests/warfare_test.js
index 55a1c75..45eb285 100644
--- a/tests/warfare_test.js
+++ b/tests/warfare_test.js
@@ -1,5 +1,5 @@
import { TestCase } from "./framework/test_case.js";
-import { smallState, cityOf } from "./framework/helpers.js";
+import { smallState, cityOf, stripPorts } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import { TRENCH } from "../shared/data/combat.js";
@@ -47,6 +47,8 @@ export class TrenchTest extends TestCase {
test_trenches_multiply_defense_and_cut_gdp() {
const state = smallState();
+ // A seeded port adds flat GDP that the trench multiplier would not scale.
+ stripPorts(state, 0);
const coords = ownLand(state);
const k = key(coords.x, coords.y);
const before = state.getTileGdpPerCapita(coords);
@@ -54,7 +56,7 @@ export class TrenchTest extends TestCase {
state._gdpPerCapitaCache.delete(k);
this.assertApprox(
state.getTileGdpPerCapita(coords),
- before * TRENCH.gdpMultiplier,
+ before * TRENCH.productionMultiplier,
1e-6,
"a trench costs the tile a fifth of its GDP per capita"
);