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 @@ + + + dummy icon + chip foundry + 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 @@ + + + dummy icon + geothermal plant + 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 @@ + + + dummy icon + offshore wind + 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 @@ + + + dummy icon + wind turbines + 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 @@
@@ -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 = $("
"); + $table.append($body); + $table.append($("").append(monthTotalRow("TOTAL", report.net))); + $list.append($table); +} + +function monthRow(label, value, cls, note) { + const $tr = $("").addClass(cls); + const $name = $("").text(label); + if (note) $name.append($("
").text(note)); + $tr.append($name, $("").text(money(value))); + return $tr; +} + +function monthTotalRow(label, value) { + const $tr = $(""); + $tr.append($("").text(label)); + $tr.append($("").text(money(value))); + return $tr; +} + +function subsectionRow(label) { + return $("").append( + $("").text(label) + ); +} + function sectionRow(label) { return $("").append( $("").text(label) diff --git a/client/js/modals/city.js b/client/js/modals/city.js index 3760de7..59651ba 100644 --- a/client/js/modals/city.js +++ b/client/js/modals/city.js @@ -3,16 +3,18 @@ // city's production queue, so the train tab shows both kinds of order. import { groupDigits, hours } from "../../../shared/text_format.js"; -import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue } from "../../../shared/data.js"; +import { ECONOMY, BUILDINGS, EFFECT_BUDGET_INCOME, effectValue, resourceById } from "../../../shared/data.js"; +import { constructionResourceCost, formatResourceAmount } from "../../../shared/resources.js"; import { buildingBuildCost, buildingRefund, buildingUpkeep, } from "../../../shared/rules.js"; -import { buildingEffectText, signedMoney } from "./format.js"; +import { buildingEffectText, signedMoney, quotedBuildCost } from "./format.js"; import { renderBudget } from "./budget.js"; import { bindTabs } from "./tabs.js"; import { ethnicityChart } from "../ui/ethnicity.js"; +import { currencySymbol } from "../currency.js"; export class CityModal { constructor() { @@ -34,6 +36,9 @@ export class CityModal { this.$budgetSub = $("#city-budget-sub"); this.$budgetList = $("#city-budget-list"); + this.$economySub = $("#city-economy-sub"); + this.$economyList = $("#city-economy-list"); + this.cityId = 0; this.gdp = 0; this.coastal = false; @@ -41,6 +46,8 @@ export class CityModal { this.constructionSpeed = 0; // Local test game: each order also offers a free, instant button. this.testing = false; + // Today's market prices, so build costs are quoted at current rates. + this.prices = {}; this._levels = {}; this.protoNames = new Map(); // Signature of the trainable set currently drawn, so a snapshot that did @@ -75,6 +82,7 @@ export class CityModal { this.coastal = !!view.coastal; this.constructionSpeed = view.constructionSpeed || 0; this.testing = !!view.testing; + this.prices = view.prices || {}; this.$title.text(`${city.name} — City`); this._renderTrainList(this.gdp, view.trainable || []); this.renderQueue(view.queue || []); @@ -87,9 +95,10 @@ export class CityModal { base: view.base || 0, approval: view.approval, }); - this.$budgetSub.text("Region net +€0/h"); + this.$budgetSub.text(`Region net +${currencySymbol()}0/h`); this.renderBudget(null); this.renderPeople(view.ethnicMakeup || [], view.approval, view.taxMultiplier, view.civs || []); + this.renderEconomy(view.cash || {}, view.cashValue || 0, view.currencies || [], view.debt || 0); this.$modal.removeClass("hidden"); } @@ -117,6 +126,52 @@ export class CityModal { } } + // The region's private-sector cash: a basket of currencies it holds. Regions + // are the game's private economic agents, so this is their own money, not the + // government treasury. + renderEconomy(cash, cashValue, currencies = [], debt = 0) { + if (!this.$economyList || !this.$economyList.length) return; + this.$economyList.empty(); + const byCode = new Map(currencies.map((entry) => [entry.code, entry])); + if (this.$economySub && this.$economySub.length) { + const owed = debt > 0 + ? ` · Debt: ${currencySymbol()}${groupDigits(Math.round(debt))}` + : ""; + this.$economySub.text( + `Private-sector cash: ${currencySymbol()}${groupDigits(Math.round(cashValue || 0))}${owed}` + ); + } + this.$economyList.append( + $("
").text( + "This region is a private economic agent: it holds its own cash reserve, " + + "trades with its neighbours and chases profit." + ) + ); + const holdings = Object.entries(cash || {}).filter(([, amount]) => amount > 0); + if (holdings.length === 0) { + this.$economyList.append($("
").text("No cash held.")); + return; + } + const $table = $("
"); + const $head = $("").append( + $("") + .append($("").text("Currency")) + .append($("").text("Held")) + ); + const $body = $(""); + for (const [code, amount] of holdings) { + const currency = byCode.get(code); + const name = currency ? `${currency.symbol} ${currency.name} (${code})` : code; + $body.append( + $("") + .append($("").text(name)) + .append($("").text(groupDigits(Math.round(amount)))) + ); + } + $table.append($head, $body); + this.$economyList.append($table); + } + // -------------------------------------------------------------- training -- // The fraction of the base build time a construction technology leaves, so a @@ -125,6 +180,34 @@ export class CityModal { return 1 / (1 + (this.constructionSpeed || 0)); } + // The full price of a construction at today's market: the build cost plus its + // material bill at current prices, whether or not the city already holds the + // materials. + _quotedCost(cost, proto, level) { + return quotedBuildCost(cost, constructionResourceCost(proto, level, cost), this.prices); + } + + // The materials a construction spends, each with its icon, so a unit's cost + // reads in resources first and the money figure stays secondary. + _resourceCostEl(resources) { + const $wrap = $(""); + for (const id of ["steel", "energy", "hightech"]) { + const amount = resources[id] || 0; + if (!(amount > 0)) continue; + const proto = resourceById(id); + $wrap.append( + $("").append( + $("") + .attr("src", `assets/${proto.icon}`) + .attr("alt", proto.name) + .attr("title", proto.name), + $("").text(formatResourceAmount(id, amount)) + ) + ); + } + return $wrap; + } + _renderTrainList(gdp, trainableUnits) { this.protoNames = new Map(); this.$trainList.empty(); @@ -140,7 +223,12 @@ export class CityModal { } const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor(); button.append( - $("").text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`) + $("").text(proto.name), + this._resourceCostEl(constructionResourceCost(proto, 0, proto.cost)), + $("").text( + `${currencySymbol()}${groupDigits(this._quotedCost(proto.cost, proto, 0))}` + ), + $("").text(hours(duration)) ); const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index; signature.push(serverIndex); @@ -295,13 +383,13 @@ export class CityModal { const sign = amount >= 0 ? "+" : "−"; $button.attr( "title", - `Expected budget income ${sign}€${groupDigits(Math.round(Math.abs(amount)))}/h` + `Expected budget income ${sign}${currencySymbol()}${groupDigits(Math.round(Math.abs(amount)))}/h` ); } refreshBuildings(budget, levels, gdp = 0, queued = {}, region = null) { this._levels = levels || {}; - this.$buildingsBudget.text(`Budget: €${groupDigits(Math.round(budget))}`); + this.$buildingsBudget.text(`Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}`); for (const row of this.buildingRows) { const level = levels[row.index] || 0; const pending = queued[row.index] || 0; @@ -312,16 +400,16 @@ export class CityModal { row.$name.text(`${row.proto.name} — ${levelText}`); row.$effects.text(buildingEffectText(row.proto, level > 0 ? level : Math.max(nextLevel, 1))); const parts = []; - if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`); + if (level > 0) parts.push(`Upkeep ${currencySymbol()}${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`); const cost = buildingBuildCost(row.proto, nextLevel); const duration = ECONOMY.trainingHours(gdp, cost) * this._constructionTimeFactor(); - parts.push(`Build cost €${groupDigits(Math.round(cost))} · ${hours(duration)}`); + parts.push(`Build cost ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))} · ${hours(duration)}`); const requirement = this._buildRequirement(row.proto); if (requirement) parts.push(requirement); row.$cost.text(parts.join(" · ")); row.$upgrade - .text(`Upgrade €${groupDigits(Math.round(cost))}`) - .prop("disabled", !!requirement || budget < cost); + .text(`Upgrade ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))}`) + .prop("disabled", !!requirement); this._setBuildTooltip(row.$upgrade, row.proto, level, nextLevel + 1, region); if (row.$free) { // Free does not ignore placement rules (a port still needs the sea). @@ -332,7 +420,7 @@ export class CityModal { } if (level > 0) { row.$downgrade - .text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, level)))}`) + .text(`Downgrade +${currencySymbol()}${groupDigits(Math.round(buildingRefund(row.proto, level)))}`) .prop("disabled", false); } else { row.$downgrade.text("Not built").prop("disabled", true); diff --git a/client/js/modals/format.js b/client/js/modals/format.js index 7a1ff1b..74771d4 100644 --- a/client/js/modals/format.js +++ b/client/js/modals/format.js @@ -2,12 +2,36 @@ import { groupDigits } from "../../../shared/text_format.js"; import { policyById } from "../../../shared/data.js"; +import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js"; +import { currencySymbol } from "../currency.js"; + +// The world market's current prices, from the snapshot's market listing. +export function marketPricesFrom(snapshot) { + const prices = {}; + for (const entry of (snapshot && snapshot.resourceMarket) || []) { + prices[entry.id] = entry.price; + } + return prices; +} + +// A fixed money cost quoted at today's market: a cost with no material bill of +// its own is scaled by the global price index, so every euro figure drifts with +// commodity prices. +export function quotedMoney(amount, prices) { + return Math.round((amount || 0) * marketPriceIndex(prices)); +} + +// A construction's full price at today's market: the fixed build cost plus the +// market value of its whole material bill, ignoring anything already in store. +export function quotedBuildCost(buildCost, materials, prices) { + return Math.round((buildCost || 0) + resourceMarketValue(materials, prices)); +} export function formatEffect(effect, level) { const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent); switch (effect.stat) { - case "gdp": - return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% GDP/capita`; + case "production": + return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% production`; case "budget_income": return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% tax income`; case "research": @@ -49,9 +73,12 @@ export function buildingEffectText(proto, level) { } case "air_defense": return "Fires one volley at hostile aircraft over the city"; - case "port_gdp": { - const per = (proto.gdpPerCapitaPerSeaTilePerLevel || 0) * level; - return `+${groupDigits(per)} GDP/capita per adjacent sea tile to the region`; + case "port_production": { + const per = (proto.productionPerSeaTilePerLevel || 0) * level; + const food = (proto.foodPerSeaTilePerLevel || 0) * level * 100; + const parts = [`+${groupDigits(per)} production per person per adjacent sea tile to the region`]; + if (food > 0) parts.push(`+${food.toFixed(1)}% food output per adjacent sea tile`); + return parts.join(" · "); } case "air_immigration": return "Immigration from every other city with an airport"; @@ -63,7 +90,12 @@ export function buildingEffectText(proto, level) { export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" }; export function signedMoney(value) { - return `${value < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(value)))}/h`; + return `${value < 0 ? "−" : "+"}${currencySymbol()}${groupDigits(Math.round(Math.abs(value)))}/h`; +} + +// A signed money amount with no period suffix, for the monthly budget report. +export function money(value) { + return `${value < 0 ? "−" : "+"}${currencySymbol()}${groupDigits(Math.round(Math.abs(value)))}`; } export function percent(value) { diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 8e22d89..56171ff 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -5,11 +5,13 @@ import { governmentChangeCost, prerequisitesMet, } from "../../../shared/rules.js"; -import { POLICIES, PROPAGANDA, policyById, TECH_THEMES, technologyCost } from "../../../shared/data.js"; -import { renderBudget } from "./budget.js"; -import { effectsText, signedMoney } from "./format.js"; +import { POLICIES, PROPAGANDA, policyById, TECH_THEMES, technologyCost, RESOURCES } from "../../../shared/data.js"; +import { formatResourceAmount } from "../../../shared/resources.js"; +import { renderMonthlyBudget } from "./budget.js"; +import { effectsText, money, quotedMoney } from "./format.js"; import { bindTabs } from "./tabs.js"; import { ethnicityChart } from "../ui/ethnicity.js"; +import { currencySymbol } from "../currency.js"; export class NationModal { constructor() { @@ -19,7 +21,25 @@ export class NationModal { this.$technologySub = $("#technology-sub"); this.$technologyList = $("#technology-list"); this.$budgetSub = $("#budget-sub"); + this.$budgetMonths = $("#budget-months"); this.$budgetList = $("#budget-list"); + this.$economySub = $("#economy-sub"); + this.$economySubtabs = $("#economy-subtabs"); + this.$economySummary = $("#economy-summary"); + this.$economyCentralBank = $("#economy-central-bank"); + this.$economyRates = $("#economy-rates"); + // Which Economy sub-tab is open: the private sector, the central bank or + // the exchange rates. + this.economyView = "summary"; + // The central-bank interest input is built once and only its value updated, + // so typing is not interrupted by the snapshot refresh. + this._centralBankBuilt = false; + this.$resourcesSub = $("#resources-sub"); + this.$resourcesList = $("#resources-list"); + this.$resourceTradeList = $("#resource-trade-list"); + // Which Resources sub-tab is open: the nation's own stores and trade, or + // the world ranking of each commodity's major exporter and importer. + this.resourceView = "stores"; this.$diplomacySub = $("#diplomacy-sub"); this.$diplomacyList = $("#diplomacy-list"); this.$citiesSub = $("#cities-sub"); @@ -43,9 +63,12 @@ export class NationModal { // Conflict cards the player expanded, keyed "a:b". Snapshot refreshes // rebuild the list, so the open/closed state has to live outside the DOM. this.expandedConflicts = new Set(); - // Building types whose per-city budget breakdown is expanded. - this.expandedBudgetGroups = new Set(); - this._lastBreakdown = {}; + // How many months back the budget tab is paging: 0 is the current month. + this.budgetOffset = 0; + this._treasury = 0; + this._budgetMonths = []; + // Expense categories whose per-resource purchase detail is expanded. + this.expandedBudgetCategories = new Set(); this.politics = null; // The Politics tab is built once and only its lists are refreshed, so the // wizard and dropdowns survive the snapshots that arrive while it is open. @@ -61,6 +84,7 @@ export class NationModal { this.onSetCampaignSpending = () => {}; this.onPolicy = () => {}; this.onRevokePolicy = () => {}; + this.onSetInterestRate = () => {}; this.onConfirm = () => {}; this.$modal.find(".modal-close").on("click", () => this.close()); bindTabs(this.$modal, "tab-"); @@ -70,12 +94,32 @@ export class NationModal { return !this.$modal.hasClass("hidden"); } - show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null) { + // Today's world market prices, from the Resources tab's data. Used to quote + // the money costs that carry no material bill of their own. + _prices() { + if (this.resources && this.resources.summary && this.resources.summary.prices) { + return this.resources.summary.prices; + } + const prices = {}; + for (const entry of (this.resources && this.resources.market) || []) { + prices[entry.id] = entry.price; + } + return prices; + } + + // A fixed money cost quoted at today's market. + _quoted(amount) { + return quotedMoney(amount, this._prices()); + } + + show(governments, currentGovernment, technologies, researched, gdp, budget, researchRate, finances, diplomacy = null, conflicts = [], politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) { this.governments = governments; this.technologies = technologies; this.diplomacy = diplomacy; this.conflicts = conflicts || []; this.politics = politics; + this.resources = resourcesInfo; + this.money = moneyInfo; // Rebuild the rows from scratch: reopening the panel must not stack a // second set of buttons on top of the previous one, or the stale rows keep // their old labels and disabled state and appear unresponsive. @@ -84,7 +128,9 @@ export class NationModal { this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government")); this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology")); this._buildTechnologyThemes(); - this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo); + this._buildResourceSubtabs(); + this._buildEconomySubtabs(); + this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo); this.$modal.removeClass("hidden"); } @@ -113,18 +159,282 @@ export class NationModal { } } - // The national budget, drawn by the shared table renderer. Building types - // expand to their per-city cost. - _renderBudget(breakdown) { + // The Resources tab's sub-tabs: the nation's own stores and trade, and the + // world ranking of each commodity's major exporters and importers. Both views + // are rendered together; switching only shows and hides them. + _buildResourceSubtabs() { + const $tabs = $("#resources-subtabs"); + if (!$tabs.length) return; + $tabs.empty(); + const views = [ + { id: "stores", label: "Stores & trade" }, + { id: "leaders", label: "Major traders" }, + ]; + for (const view of views) { + const $button = $("") + .attr("data-view", view.id) + .text(view.label); + if (view.id === this.resourceView) $button.addClass("active"); + $button.on("click", () => this._setResourceView(view.id)); + $tabs.append($button); + } + } + + _setResourceView(view) { + this.resourceView = view; + const $tabs = $("#resources-subtabs"); + $tabs.find(".subtab").removeClass("active"); + $tabs.find(`.subtab[data-view="${view}"]`).addClass("active"); + if (this.$resourcesList) this.$resourcesList.toggleClass("hidden", view !== "stores"); + if (this.$resourceTradeList) this.$resourceTradeList.toggleClass("hidden", view !== "leaders"); + } + + // The Economy tab's sub-tabs: the private sector, the central bank and the + // exchange-rate table. + _buildEconomySubtabs() { + if (!this.$economySubtabs || !this.$economySubtabs.length) return; + this.$economySubtabs.empty(); + const views = [ + { id: "summary", label: "Private sector" }, + { id: "centralbank", label: "Central bank" }, + { id: "rates", label: "Exchange rates" }, + ]; + for (const view of views) { + const $button = $("") + .attr("data-view", view.id) + .text(view.label); + if (view.id === this.economyView) $button.addClass("active"); + $button.on("click", () => this._setEconomyView(view.id)); + this.$economySubtabs.append($button); + } + this._setEconomyView(this.economyView); + } + + _setEconomyView(view) { + this.economyView = view; + if (this.$economySubtabs) { + this.$economySubtabs.find(".subtab").removeClass("active"); + this.$economySubtabs.find(`.subtab[data-view="${view}"]`).addClass("active"); + } + if (this.$economySummary) this.$economySummary.toggleClass("hidden", view !== "summary"); + if (this.$economyCentralBank) { + this.$economyCentralBank.toggleClass("hidden", view !== "centralbank"); + } + if (this.$economyRates) this.$economyRates.toggleClass("hidden", view !== "rates"); + // Render the view that just became visible. + this._renderEconomy(this.money); + } + + _renderEconomy(money) { + this.money = money || this.money || {}; + if (this.economyView === "centralbank") this._renderCentralBank(this.money); + else if (this.economyView === "rates") this._renderExchangeRates(this.money); + else this._renderPrivateSector(this.money); + } + + // The aggregate private-sector cash the nation's regions hold, and each + // region's own reserve. + _renderPrivateSector(money) { + if (!this.$economySummary || !this.$economySummary.length) return; + const cash = money.privateCash || 0; + if (this.$economySub && this.$economySub.length) { + this.$economySub.text( + `Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash))} · ` + + `${(money.currency && money.currency.name) || ""}` + ); + } + this.$economySummary.empty(); + this.$economySummary.append( + $("
").text( + "Regions are private economic agents: each holds its own cash reserve, " + + "trades with its neighbours and chases profit." + ) + ); + const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv); + if (cities.length === 0) return; + const $table = $("
"); + const $head = $("").append( + $("") + .append($("").text("Region")) + .append($("").text("Private cash")) + ); + const $body = $(""); + let total = 0; + for (const city of cities) { + total += city.cashValue || 0; + $body.append( + $("") + .append($("").text(city.name)) + .append( + $("").text( + `${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}` + ) + ) + ); + } + $table.append($head, $body); + const $foot = $("").append( + $("") + .append($("").text("Total")) + .append($("").text( + `${currencySymbol()}${groupDigits(Math.round(total))}` + )) + ); + $table.append($foot); + this.$economySummary.append($table); + } + + // The central bank: its interest rate, which compounds into every region's + // private cash, and the foreign reserves it has accumulated. + _renderCentralBank(money) { + if (!this.$economyCentralBank || !this.$economyCentralBank.length) return; + const bank = money.centralBank || { rate: 0, reserves: [] }; + if (this.$economySub && this.$economySub.length) { + this.$economySub.text( + `${(money.currency && money.currency.name) || "Central bank"} · ` + + `Interest rate ${(bank.rate * 100).toFixed(2)}%/year` + ); + } + if (!this._centralBankBuilt) { + this.$economyCentralBank.empty(); + const $rate = $("
"); + $rate.append($("").text("Interest rate (% per year)")); + const $input = $(""); + const $apply = $("").text("Set"); + const apply = () => { + const percent = Number($input.val()); + if (!Number.isFinite(percent) || percent < 0) return; + this.onSetInterestRate(percent / 100); + }; + $apply.on("click", apply); + $input.on("keydown", (event) => { + if (event.key === "Enter") apply(); + }); + $rate.append($input, $apply); + this.$economyCentralBank.append($rate); + this.$economyCentralBank.append( + $("
").text( + "Regions borrow from the bank at this rate when their cash runs out, " + + "and it compounds on whatever they owe. The government may run an " + + "overdraft here too." + ) + ); + this.$economyCentralBank.append($("
")); + const $reserves = $("
"); + this.$economyCentralBank.append($reserves); + this._centralBankBuilt = true; + } + const $input = this.$economyCentralBank.find("#cb-rate"); + if ($input.length && document.activeElement !== $input.get(0)) { + $input.val((bank.rate * 100).toFixed(2)); + } + this.$economyCentralBank.find(".central-bank-loans").text( + `Outstanding loans: ${currencySymbol()}${groupDigits(Math.round(bank.loans || 0))}` + ); + const $reserves = this.$economyCentralBank.find(".central-bank-reserves"); + $reserves.empty(); + $reserves.append($("
").text("Foreign reserves")); + if ((bank.reserves || []).length === 0) { + $reserves.append($("
").text("No foreign currency held yet.")); + return; + } + const $table = $("
"); + const $body = $(""); + for (const reserve of bank.reserves) { + $body.append( + $("") + .append($("").text(`${reserve.symbol} ${reserve.name}`)) + .append( + $("").text( + groupDigits(Math.round(reserve.amount || 0)) + ) + ) + ); + } + $table.append($body); + $reserves.append($table); + } + + // The exchange rate between the viewer's currency and every other. + _renderExchangeRates(money) { + if (!this.$economyRates || !this.$economyRates.length) return; + const mine = money.currency || { name: "Currency" }; + if (this.$economySub && this.$economySub.length) { + this.$economySub.text(`Exchange rates against the ${mine.name}`); + } + this.$economyRates.empty(); + const rows = money.exchangeRates || []; + const $table = $("
"); + const $head = $("").append( + $("") + .append($("").text("Currency")) + .append($("").text(`1 ${mine.code || ""} buys`)) + ); + const $body = $(""); + for (const entry of rows) { + $body.append( + $("") + .append($("").text(`${entry.symbol} ${entry.name} (${entry.code})`)) + .append($("").text(entry.rate.toFixed(4))) + ); + } + $table.append($head, $body); + this.$economyRates.append($table); + } + + // The national budget: the selected month's actual income and expenses, with + // a pager back through the stored monthly digests. The treasury stays on the + // subtitle so the table is purely the month's flows. + _renderBudget(finances) { if (!this.$budgetList || !this.$budgetList.length) return; - this._lastBreakdown = breakdown || {}; - renderBudget(this.$budgetList, this._lastBreakdown, { - expanded: this.expandedBudgetGroups, - onToggle: () => this._renderBudget(this._lastBreakdown), - showUnits: true, + const months = (finances && finances.months) || []; + this._budgetMonths = months; + const maxOffset = Math.max(0, months.length - 1); + if (this.budgetOffset > maxOffset) this.budgetOffset = maxOffset; + if (this.budgetOffset < 0) this.budgetOffset = 0; + const index = months.length - 1 - this.budgetOffset; + const report = index >= 0 ? months[index] : null; + this._renderBudgetMonthsNav(months, index); + if (this.$budgetSub && this.$budgetSub.length) { + const net = report ? ` · ${report.label}: ${money(report.net)}` : ""; + this.$budgetSub.text(`Treasury: ${currencySymbol()}${groupDigits(Math.round(this._treasury))}${net}`); + } + renderMonthlyBudget(this.$budgetList, report, { + expanded: this.expandedBudgetCategories, + onToggle: () => this._renderBudget({ months }), }); } + _renderBudgetMonthsNav(months, index) { + if (!this.$budgetMonths || !this.$budgetMonths.length) return; + this.$budgetMonths.empty(); + if (months.length === 0) return; + const older = this.budgetOffset < months.length - 1; + const newer = this.budgetOffset > 0; + const $prev = $("") + .text("◀") + .prop("disabled", !older) + .on("click", () => { + if (!older) return; + this.budgetOffset += 1; + this._renderBudget({ months }); + }); + const $next = $("") + .text("▶") + .prop("disabled", !newer) + .on("click", () => { + if (!newer) return; + this.budgetOffset -= 1; + this._renderBudget({ months }); + }); + const label = months[index] ? months[index].label : ""; + this.$budgetMonths.append( + $prev, + $("").text(label), + $next + ); + } + // The nation's regions: each city's population, GDP and the tax it collects. _renderCities(cities) { if (!this.$citiesList || !this.$citiesList.length) return; @@ -170,8 +480,8 @@ export class NationModal { const $name = $("").text(city.isCapital ? `★ ${city.name}` : city.name); $tr.append($name); $tr.append($("").text(compact(city.population))); - $tr.append($("").text(`€${compact(city.gdp)}`)); - $tr.append($("").text(`+€${compact(city.income)}`)); + $tr.append($("").text(`${currencySymbol()}${compact(city.gdp)}`)); + $tr.append($("").text(`+${currencySymbol()}${compact(city.income)}`)); $body.append($tr); } $table.append($head, $body); @@ -180,13 +490,201 @@ export class NationModal { $("") .append($("").text("TOTAL")) .append($("").text(compact(totalPopulation))) - .append($("").text(`€${compact(totalGdp)}`)) - .append($("").text(`+€${compact(totalIncome)}`)) + .append($("").text(`${currencySymbol()}${compact(totalGdp)}`)) + .append($("").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 = $("
"); + const columns = [ + { label: "Resource" }, + { label: "Stored", numeric: true }, + { label: "Consumed / day", numeric: true }, + { label: "Made / day", numeric: true }, + { label: "Exported / day", numeric: true }, + { label: "Imported / day", numeric: true }, + { label: "Market price", numeric: true }, + { label: "World supply / day", numeric: true }, + { label: "World demand / day", numeric: true }, + ]; + const $head = $(""); + const $headRow = $(""); + for (const column of columns) { + const $th = $("").text(column.label); + if (column.numeric) $th.addClass("numeric"); + $headRow.append($th); + } + $head.append($headRow); + const $body = $(""); + for (const proto of RESOURCES) { + const energy = proto.id === "energy"; + const stats = market.get(proto.id) || {}; + const stored = energy ? "—" : formatResourceAmount(proto.id, summary.stock[proto.id] || 0); + const consumed = energy + ? formatResourceAmount("energy", (summary.consumed && summary.consumed.energy) || 0) + : formatResourceAmount(proto.id, (summary.consumed && summary.consumed[proto.id]) || 0); + const made = energy + ? formatResourceAmount("energy", summary.energy ? summary.energy.supply : 0) + : formatResourceAmount(proto.id, summary.production[proto.id] || 0); + const exported = formatResourceAmount( + proto.id, + (trade.exports && trade.exports[proto.id]) || 0 + ); + const imported = formatResourceAmount( + proto.id, + (trade.imports && trade.imports[proto.id]) || 0 + ); + const $tr = $(""); + const $name = $(""); + $name.append( + $("") + .attr("src", `assets/${proto.icon}`) + .attr("alt", proto.name) + ); + $name.append($("").text(` ${proto.name}`)); + $tr.append($name); + $tr.append($("").text(stored)); + $tr.append($("").text(consumed)); + $tr.append($("").text(made)); + $tr.append($("").text(exported)); + $tr.append($("").text(imported)); + $tr.append($("").text(this._resourcePriceText(proto, stats.price))); + $tr.append( + $("").text( + formatResourceAmount(proto.id, Math.max(0, stats.supply || 0)) + ) + ); + $tr.append( + $("").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 = $("
"); + const columns = [ + { label: "Resource" }, + { label: "Major exporter" }, + { label: "Exported / day", numeric: true }, + { label: "Major importer" }, + { label: "Imported / day", numeric: true }, + ]; + const $head = $(""); + const $headRow = $(""); + for (const column of columns) { + const $th = $("").text(column.label); + if (column.numeric) $th.addClass("numeric"); + $headRow.append($th); + } + $head.append($headRow); + const $body = $(""); + for (const proto of RESOURCES) { + const best = (kind) => { + let winner = null; + for (const entry of trade) { + const amount = (entry[kind] && entry[kind][proto.id]) || 0; + if (amount > 0 && (!winner || amount > winner.amount)) { + winner = { civ: entry.civ, amount }; + } + } + return winner; + }; + const exporter = best("exports"); + const importer = best("imports"); + const $tr = $(""); + const $name = $(""); + $name.append( + $("") + .attr("src", `assets/${proto.icon}`) + .attr("alt", proto.name) + ); + $name.append($("").text(` ${proto.name}`)); + $tr.append($name); + $tr.append($("").text(exporter ? name(exporter.civ) : "—")); + $tr.append( + $("").text( + exporter ? formatResourceAmount(proto.id, exporter.amount) : "—" + ) + ); + $tr.append($("").text(importer ? name(importer.civ) : "—")); + $tr.append( + $("").text( + importer ? formatResourceAmount(proto.id, importer.amount) : "—" + ) + ); + $body.append($tr); + } + $table.append($head, $body); + this.$resourceTradeList.append($table); + this.$resourceTradeList.append( + $("
").text( + "The largest cross-nation deliveries of the last day, in each resource. " + + "Only trade between nations is counted, so goods bought from the global " + + "market never make a nation an importer here." + ) + ); + } + + // A market price reads in euro per the resource's own unit; tiny energy prices + // keep enough decimals to be legible. + _resourcePriceText(proto, price) { + const value = price === undefined || price === null ? 0 : price; + const text = value >= 1000 + ? groupDigits(Math.round(value)) + : value >= 1 + ? value.toFixed(0) + : value.toFixed(4); + return `${currencySymbol()}${text} / ${proto.unit}`; + } + _makeRow($list, proto, index, kind) { const $card = $("
"); const $info = $("
"); @@ -207,29 +705,29 @@ export class NationModal { return { proto, index, kind, $name, $effects, $cost, $action, $card }; } - refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null) { + refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) { this.currentGovernment = currentGovernment; if (techInfo) this.techInfo = techInfo; + if (resourcesInfo) this.resources = resourcesInfo; this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); if (diplomacy !== null) this.diplomacy = diplomacy; if (conflicts !== null) this.conflicts = conflicts || []; if (politics !== null) this.politics = politics; this.$governmentSub.text( - `Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}` + `Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}` ); // Research has no pool: every point flows into the focused technology, so // the panel shows the rate instead of a stockpile. this.$technologySub.text(`Research: +${(researchRate || 0).toFixed(1)}/h`); - const breakdown = finances || {}; - const net = breakdown.net || 0; + const fin = finances || {}; + this._treasury = budget; - this.$budgetSub.text( - `Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}` - ); - - this._renderBudget(breakdown); - this._renderCities(breakdown.cities); + if (moneyInfo) this.money = moneyInfo; + this._renderBudget(fin); + this._renderCities(fin.cities); + this._renderResources(this.resources); + this._renderEconomy(this.money); for (const row of this.governmentRows) { const proto = row.proto; @@ -240,8 +738,10 @@ export class NationModal { row.$action.text("Current government").prop("disabled", true); } else { const cost = governmentChangeCost(proto, gdp); - row.$cost.text(`Adoption cost €${groupDigits(Math.round(cost))}`); - row.$action.text(`Adopt €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost); + row.$cost.text(`Adoption cost ${currencySymbol()}${groupDigits(this._quoted(cost))}`); + row.$action + .text(`Adopt ${currencySymbol()}${groupDigits(this._quoted(cost))}`) + .prop("disabled", false); } } @@ -370,8 +870,8 @@ export class NationModal { const $tr = $(""); if (row.self) $tr.addClass("local-nation"); $tr.append($("").text(row.name)); - $tr.append($("").text(`€${compact(row.gdp)}`)); - $tr.append($("").text(`€${compact(row.gdpPerCapita)}`)); + $tr.append($("").text(`${currencySymbol()}${compact(row.gdp)}`)); + $tr.append($("").text(`${currencySymbol()}${compact(row.gdpPerCapita)}`)); $tr.append($("").text(compact(row.population))); $tr.append($("").text(`${Math.round(row.approval * 100)}%`)); const $status = $(""); @@ -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" );