From 3e429ccb649c0baf351827dd4677ad97756cbb5f Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Tue, 22 Sep 2026 14:34:14 +0200 Subject: [PATCH] Gave production buildings their own economy and fed island regions Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply. Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory. Bundles the in-progress tree cleanup that was already present in the working tree. --- FIXME.md | 3 + client/js/currency.js | 9 - client/js/feed.js | 4 - client/js/game_screen/panels.js | 74 ++++-- client/js/map_view/constants.js | 15 +- client/js/map_view/entities.js | 4 +- client/js/map_view/webgl.js | 6 - client/js/modals/nation.js | 95 ++++++-- scripts/simulate_resources.js | 3 - shared/data/combat.js | 6 +- shared/data/politics.js | 2 - shared/data/resources.js | 56 ++++- shared/game_state.js | 6 + shared/game_state/air.js | 6 +- shared/game_state/economy.js | 11 +- shared/game_state/industry.js | 374 +++++++++++++++++++++++++++++ shared/game_state/money.js | 27 ++- shared/game_state/politics.js | 8 - shared/game_state/resources.js | 257 ++++++++++++++++---- shared/game_state/serialization.js | 7 + shared/game_state/statuses.js | 44 ---- shared/game_state/warfare.js | 25 +- shared/game_state/world.js | 1 - shared/resources.js | 44 +++- shared/rules.js | 6 - tests/air_test.js | 2 +- tests/food_synthesis_test.js | 121 ++++++++++ tests/game_screen_test.js | 37 ++- tests/game_state_buildings_test.js | 2 +- tests/industry_test.js | 248 +++++++++++++++++++ tests/money_test.js | 1 - tests/political_map_test.js | 2 +- tests/regression_test.js | 1 - tests/siege_test.js | 23 +- 34 files changed, 1253 insertions(+), 277 deletions(-) create mode 100644 FIXME.md create mode 100644 shared/game_state/industry.js create mode 100644 tests/food_synthesis_test.js create mode 100644 tests/industry_test.js diff --git a/FIXME.md b/FIXME.md new file mode 100644 index 0000000..2ae0d16 --- /dev/null +++ b/FIXME.md @@ -0,0 +1,3 @@ +* In some circumstances, it seems likes aircraft cannot bomb within their own range, as per an error message reported by a user. +* War crime (pillaging) seems to have a permanent effect on popularity that I just cannot get rid off. War crime popularity debuff should be instant and only once per occurence. +* Double airplanes range diff --git a/client/js/currency.js b/client/js/currency.js index 4c8c642..625f688 100644 --- a/client/js/currency.js +++ b/client/js/currency.js @@ -2,8 +2,6 @@ // 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) { @@ -13,10 +11,3 @@ export function setCurrency(currency) { 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/feed.js b/client/js/feed.js index 44ff7f3..189327c 100644 --- a/client/js/feed.js +++ b/client/js/feed.js @@ -34,10 +34,6 @@ export class GameFeed { return $line; } - lineCount() { - return this.$container ? this.$container.children(".feed-line").length : 0; - } - _retire($line) { $line.addClass("leaving"); if (this.fade <= 0) { diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 5933007..9b2128b 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, marketPricesFrom, quotedMoney, quotedBuildCost } from "../modals.js"; +import { describeNews, marketPricesFrom, quotedBuildCost } from "../modals.js"; import { compact, groupDigits, hours, improvements as fmtImprovements } from "../../../shared/text_format.js"; import { CIVILISATIONS, @@ -28,12 +28,12 @@ import { resourceById, RESOURCES, RESOURCE_RULES, + resourceBuildingOutputAt, tileImprovementById, - AIR_STRIKE, } from "../../../shared/data.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 { dateString, hoursInNextMonth } from "../../../shared/game_clock.js"; import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js"; import { ethnicityChart } from "../ui/ethnicity.js"; import { isEconomicMode } from "../map_view/economic.js"; @@ -386,12 +386,11 @@ export const panelMethods = { }, // Why an air strike cannot be issued right now, or null when it can. Blocks a - // plane that is already flying a mission, is still re-arming, or whose - // nation cannot afford the (deliberately steep) price. + // plane that is already flying a mission or is still re-arming, and a nation + // whose stores cannot cover the munitions the sortie burns. _airStrikeBlocked(units) { const snapshot = this.snapshot || {}; const totalHours = snapshot.totalHours || 0; - const budget = (snapshot.viewerStats || {}).budget || 0; for (const unit of units) { if (unit.strikeTarget) { return { title: "Aircraft engaged", message: "This aircraft is already on a strike mission." }; @@ -404,12 +403,16 @@ export const panelMethods = { }; } } - const cost = (AIR_STRIKE.cost || 0) * units.length; - if (cost > budget) { - const prices = marketPricesFrom(this.snapshot); + // A sortie is paid for in munitions, not money (GameState.requestAirStrike): + // every aircraft spends steel and high-tech from the nation's stores. + const per = RESOURCE_RULES.combatConsumption.airStrike || {}; + const neededSteel = (per.steel || 0) * units.length; + const neededTech = (per.hightech || 0) * units.length; + const stock = (((snapshot.viewerStats || {}).resources || {}).stock) || {}; + if (neededSteel > (stock.steel || 0) + 1e-6 || neededTech > (stock.hightech || 0) + 1e-6) { return { - title: "Insufficient budget", - message: `An air strike costs ${currencySymbol()}${groupDigits(quotedMoney(cost, prices))}, which the treasury cannot cover.`, + title: "Insufficient munitions", + message: "The nation's stores cannot cover this air strike's steel and high-tech.", }; } return null; @@ -937,7 +940,10 @@ export const panelMethods = { const militaryWorks = this.map.tileImprovements.get(k); if (militaryWorks) { const proto = tileImprovementById(militaryWorks.id); - works.push(proto ? proto.name : militaryWorks.id); + const level = militaryWorks.level || 0; + works.push( + (proto ? proto.name : militaryWorks.id) + (level > 0 ? ` (level ${level})` : "") + ); } $("#tile-improvements").text( `Improvements: ${fmtImprovements(Array.from(new Set([...cityImprovements, ...transport, ...works])))}` @@ -969,19 +975,33 @@ export const panelMethods = { const work = queue.find( (entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y ); - if (!work) return; - 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; - const pct = Math.max(0, Math.min(100, (elapsed / total) * 100)); - const remaining = Math.max(0, Math.ceil(total - elapsed)); - const $bar = $("
"); - $bar.append($("
").css("width", `${pct}%`)); - $el.append( - $("
").text(`Building ${name} — ${remaining} h left`), - $bar + if (work) { + 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; + const pct = Math.max(0, Math.min(100, (elapsed / total) * 100)); + const remaining = Math.max(0, Math.ceil(total - elapsed)); + const $bar = $("
"); + $bar.append($("
").css("width", `${pct}%`)); + $el.append( + $("
").text(`Building ${name} — ${remaining} h left`), + $bar + ); + } + // A production building upgrades itself: show the work its agent has begun. + const stats = this.snapshot.viewerStats || {}; + const building = (stats.productionBuildings || []).find( + (entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y ); + if (building && building.upgrading) { + $el.append( + $("
").text( + `Upgrading ${building.name} to level ${(building.level || 0) + 1}` + + ` — ${building.upgradeDaysLeft || 0} days left` + ) + ); + } }, // The city panel's quick view of what is currently being produced there, so @@ -1372,7 +1392,7 @@ export const panelMethods = { 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; + amounts[proto.resource] += resourceBuildingOutputAt(proto, improvement.level) * multiplier; } return { amounts, factors, foodMultiplier }; }, @@ -1784,6 +1804,10 @@ export const panelMethods = { exchangeRates: stats.exchangeRates || [], centralBank: stats.centralBank || { rate: 0, reserves: [] }, privateCash: stats.privateCash || 0, + // Production buildings are private-sector agents of their own, held apart + // from the regions in the Economy tab. + privateBuildingCash: stats.privateBuildingCash || 0, + productionBuildings: stats.productionBuildings || [], // Where the viewer's own currency sits: treasury, regions, foreign banks. currencyHoldings: stats.currencyHoldings || null, // The viewer's trade-tax rates and the day's take, for the Taxes sub-tab. diff --git a/client/js/map_view/constants.js b/client/js/map_view/constants.js index 914fb2c..9ffae6f 100644 --- a/client/js/map_view/constants.js +++ b/client/js/map_view/constants.js @@ -25,12 +25,11 @@ export const DRAG_THRESHOLD = 5; export const ZOOM_MIN = 0.2; export const ZOOM_MAX = 2.0; -// The camera looks north from an orthographic elevation of 70° above the -// ground. That foreshortens the north-south axis by sin(70°) ~= 0.94 while the -// east-west axis is untouched: a gentle 3D tilt that keeps the current -// orientation. It is applied to the terrain, roads, borders, fog and ground -// overlays; units, cities and labels stay upright on top. -export const CAMERA_ELEVATION_DEG = 45; +// The camera looks north from an orthographic elevation of 45° above the +// ground. That foreshortens the north-south axis by sin(45°) ~= 0.71 while the +// east-west axis is untouched. It is applied to the terrain, roads, borders, +// fog and ground overlays; units, cities and labels stay upright on top. +const CAMERA_ELEVATION_DEG = 45; export const CAMERA_TILT = Math.sin((CAMERA_ELEVATION_DEG * Math.PI) / 180); // How far the tallest terrain (normalised height 1, see map_view/heightmap.js) @@ -105,10 +104,8 @@ export function politicalMode(zoom) { } // A battle spreads the two stacks to either side of the tile centre, with the -// battle icon between them. Side offset is in pixels; step is the spacing -// between the (half-size) units of one stack. +// battle icon between them. The side offset is in pixels. export const BATTLE_SIDE_OFFSET = 15; -export const BATTLE_STACK_STEP = 9; // The detailed view is built from square blocks of tiles. Only the blocks that // intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js index 5a9d2ff..da43ec6 100644 --- a/client/js/map_view/entities.js +++ b/client/js/map_view/entities.js @@ -89,8 +89,8 @@ export const entityMethods = { this._warfareSignature = signature; this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1]))); this.tileImprovements = new Map(); - for (const [x, y, id, owner, hp] of improvements) { - this.tileImprovements.set(key(x, y), { id, owner, hp }); + for (const [x, y, id, owner, hp, level] of improvements) { + this.tileImprovements.set(key(x, y), { id, owner, hp, level: level || 0 }); } this._renderWarfare(); }, diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js index 1991949..6bdcb5a 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -875,12 +875,6 @@ export class GLMapRenderer { ); } - // Fog has no texture, so it uses a compact position/height/colour vertex. That - // keeps the buffer (re-uploaded whenever visibility changes) small. - _pushSimple(arr, x, y, height, colour) { - arr.push(x, y, height || 0, colour[0], colour[1], colour[2], colour[3]); - } - // Adds a convex quad given its four vertices (with per-vertex heights) as two // triangles. Wind order does not matter: we never cull faces. _quad(arr, corners, colour) { diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 5988658..9850fc5 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -443,15 +443,18 @@ export class NationModal { return `${currencySymbol()}${groupDigits(Math.round(value || 0))}`; } - // The aggregate private-sector cash the nation's regions hold, and each - // region's own reserve. + // The aggregate private-sector cash the nation's regions and its independent + // production buildings hold, each listed on its own row. _renderPrivateSector(money) { if (!this.$economySummary || !this.$economySummary.length) return; const cash = money.privateCash || 0; + const buildingCash = money.privateBuildingCash || 0; if (this.$economySub && this.$economySub.length) { this.$economySub.text( - `Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash))} · ` + - `${(money.currency && money.currency.name) || ""}` + `Private-sector money: ${currencySymbol()}${groupDigits(Math.round(cash + buildingCash))}` + + ` · regions ${currencySymbol()}${groupDigits(Math.round(cash))}` + + ` · production ${currencySymbol()}${groupDigits(Math.round(buildingCash))}` + + ` · ${(money.currency && money.currency.name) || ""}` ); } this.$economySummary.empty(); @@ -462,36 +465,94 @@ export class NationModal { ) ); const cities = (money.cities || []).filter((city) => city.civ === money.viewerCiv); - if (cities.length === 0) return; + if (cities.length > 0) { + 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); + } + this._renderProductionBuildings(money.productionBuildings || []); + } + + // Production buildings are private-sector agents too, but a separate class + // from the regions: each keeps its own cash and reinvests it in upgrades. + _renderProductionBuildings(buildings) { + if (!this.$economySummary || buildings.length === 0) return; + this.$economySummary.append( + $("
").text( + "Production buildings are independent private agents: they keep what " + + "their output sells for, and spend it upgrading when demand has grown." + ) + ); const $table = $("
"); const $head = $("").append( $("") - .append($("").text("Region")) + .append($("").text("Production building")) + .append($("").text("Output / day")) + .append($("").text("Level")) .append($("").text("Private cash")) ); const $body = $(""); let total = 0; - for (const city of cities) { - total += city.cashValue || 0; + for (const building of buildings) { + total += building.cash || 0; + const level = building.level || 0; + const resource = resourceById(building.resource); + const $output = resource && building.outputPerDay + ? $("").text(formatResourceAmount(building.resource, building.outputPerDay)) + : $("").text("—"); $body.append( $("") - .append($("").text(city.name)) + .append($("").text(building.name)) + .append($output) .append( $("").text( - `${currencySymbol()}${groupDigits(Math.round(city.cashValue || 0))}` + level + (building.upgrading ? " ↑" : "") + ) + ) + .append( + $("").text( + `${currencySymbol()}${groupDigits(Math.round(building.cash || 0))}` ) ) ); } $table.append($head, $body); - const $foot = $("").append( - $("") - .append($("").text("Total")) - .append($("").text( - `${currencySymbol()}${groupDigits(Math.round(total))}` - )) + $table.append( + $("").append( + $("") + .append($("").text("Total")) + .append($("").text( + `${currencySymbol()}${groupDigits(Math.round(total))}` + )) + ) ); - $table.append($foot); this.$economySummary.append($table); } diff --git a/scripts/simulate_resources.js b/scripts/simulate_resources.js index 0a7e429..0053d48 100644 --- a/scripts/simulate_resources.js +++ b/scripts/simulate_resources.js @@ -14,9 +14,6 @@ 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"; diff --git a/shared/data/combat.js b/shared/data/combat.js index 20c4533..08c2792 100644 --- a/shared/data/combat.js +++ b/shared/data/combat.js @@ -56,10 +56,10 @@ export const CITY_BOMBARD = { // Aircraft sorties: a plane flies to its target, bombs once, returns to a // friendly airport and then cannot strike again for `cooldownHours` (two days). -// Each attacking aircraft costs `cost` from its nation's budget, charged up -// front so bombing cannot be spammed for free. +// Each attacking aircraft burns munitions from its nation's stores (see +// RESOURCE_RULES.combatConsumption.airStrike), charged up front so bombing +// cannot be spammed for free. export const AIR_STRIKE = { - cost: 10_000_000, cooldownHours: 48, }; diff --git a/shared/data/politics.js b/shared/data/politics.js index 286e123..fee7623 100644 --- a/shared/data/politics.js +++ b/shared/data/politics.js @@ -84,8 +84,6 @@ export const PROPAGANDA = { export const CAMPAIGN_ETHNICITY = "ethnicity"; export const CAMPAIGN_GOVERNMENT = "government"; -export const CAMPAIGN_WARM = 1; -export const CAMPAIGN_COOL = -1; // Minority policies a government may proclaim. Any number may be active at // once, but the same policy may not be enacted twice for the same ethnicity. diff --git a/shared/data/resources.js b/shared/data/resources.js index 01983c8..91526a5 100644 --- a/shared/data/resources.js +++ b/shared/data/resources.js @@ -9,7 +9,6 @@ // 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. @@ -134,6 +133,11 @@ export const RESOURCE_RULES = { // Energy the material producers consume to make one canonical unit. energyPerFoodTonne: 1 * KWH_PER_MWH, // 1 MWh per tonne + // Food a region can force from energy beyond its own harvest, in greenhouses + // and synthesis. The energy for `x` extra tonnes a day is this coefficient + // times `x^2`, so each further tonne costs more energy than the last and a + // region grows only while its marginal energy beats the market price of food. + foodSynthesisQuadratic: 20, // kWh per tonne squared energyPerSteelTonne: 20 * KWH_PER_MWH, // 20 MWh per tonne energyPerLuxuryCarat: { gold_mine: 10 * KWH_PER_MWH, // 10 MWh per carat @@ -412,6 +416,56 @@ for (const building of RESOURCE_BUILDINGS) { if (building.takesCombatDamage === undefined) building.takesCombatDamage = true; } +// Every resource building -- a material producer or a power plant -- is an +// independent private-sector agent. It keeps its own cash from what it sells, +// tries to grow when demand for its output has been rising, and spends that +// cash to upgrade itself. Power is a paid flow: the plants are paid for the +// power the grid's consumers draw, and a plant that keeps selling out expands. +// +// An upgrade to level u raises throughput exponentially (`outputPerDay` times +// `alpha^u`) but the extra output costs more power: the operating input grows +// linearly (`1 + inputPerLevel*u`). Each further level costs `costAlpha^u` more +// steel and high-tech, so the benefit compounds slower than the bill. +export const RESOURCE_BUILDING_UPGRADE = { + alpha: 1.1, + inputPerLevel: 1, + costAlpha: 1.1, + // A ceiling on the compounding, so a very old building cannot run away. + maxLevel: 12, + // How many days of sales the agent watches for the demand trend, and how much + // its recent average must beat its older one before it commits to an upgrade. + salesWindow: 6, + demandGrowth: 0, + // A producer that sells at least this share of what it makes is demand-bound + // (it sells out), so it grows even when its sales look flat. + sellThrough: 0.9, + // The cash headroom over the quoted material bill an agent wants before it + // starts, so a price swing cannot leave an upgrade half-bought. + cashMargin: 1.15, +}; + +// A production building's output per day at upgrade `level`, exponential in the +// level. Shared by the simulation and the browser so both quote the same figure. +export function resourceBuildingOutputAt(proto, level) { + const base = (proto && proto.outputPerDay) || 0; + return base * Math.pow(RESOURCE_BUILDING_UPGRADE.alpha, Math.max(0, level || 0)); +} + +// A production building's operating input at upgrade `level`: the plant's fuel +// or a converter's energy, growing linearly with the level. `field` is the +// proto field to scale (`energyPerDay` or `fuelEnergyPerDay`). +export function resourceBuildingInputAt(proto, level, field = "energyPerDay") { + const base = (proto && proto[field]) || 0; + return base * (1 + RESOURCE_BUILDING_UPGRADE.inputPerLevel * Math.max(0, level || 0)); +} + +// The money an agent pays to reach the next level from `level`: the build cost +// compounded by `costAlpha` per level already gained. +export function resourceBuildingUpgradeMoneyCost(proto, level) { + const base = (proto && proto.buildCost) || 0; + return base * Math.pow(RESOURCE_BUILDING_UPGRADE.costAlpha, Math.max(0, level || 0)); +} + export const RESOURCE_BUILDING_BY_ID = Object.fromEntries( RESOURCE_BUILDINGS.map((building) => [building.id, building]) ); diff --git a/shared/game_state.js b/shared/game_state.js index 85b51de..26fb46a 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -47,6 +47,7 @@ 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 { industryMethods } from "./game_state/industry.js"; import { budgetMethods } from "./game_state/budget.js"; import { moneyMethods } from "./game_state/money.js"; import { taxMethods } from "./game_state/taxes.js"; @@ -118,6 +119,9 @@ export class GameState { this.tileImprovementOwner = new Map(); this.tileImprovementHp = new Map(); this.tileWorks = new Map(); + // Production buildings as independent private-sector agents, keyed by tile: + // each keeps its own cash, upgrade level and sales history. + this.buildingAgents = new Map(); this._tileImprovementVersion = 0; // Bumped whenever the transport network changes, so the serialised road and // railway lists are rebuilt only when something actually moved. @@ -384,6 +388,7 @@ export class GameState { } this._tickBuildings(); this._tickTileWorks(); + this._tickIndustry(); this._tickEmbark(); this._tickCoastalCannons(); // A unit no longer sharing a tile with an enemy is not an invader any more. @@ -413,6 +418,7 @@ Object.assign( monthlyMethods, warfareMethods, resourceMethods, + industryMethods, budgetMethods, moneyMethods, taxMethods diff --git a/shared/game_state/air.js b/shared/game_state/air.js index bb49c63..3c747ea 100644 --- a/shared/game_state/air.js +++ b/shared/game_state/air.js @@ -288,7 +288,7 @@ export const airMethods = { // Applies one ranged blow to a target unit, with the special fates of exposed // support units, and passes a tenth of the damage on to any structures on the // tile. - _applyStrikeDamage(target, damage, attackerProto = null) { + _applyStrikeDamage(target, damage) { const proto = this.unitProto(target); const alone = this.unitsAt(target.coords).length <= 1; if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp; @@ -420,7 +420,7 @@ export const airMethods = { const target = this._strikeTargetAt(goal, unit.civ); if (target) { const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true)); - this._applyStrikeDamage(target, damage, this.unitProto(unit)); + this._applyStrikeDamage(target, damage); } else { const city = this._strikeCityAt(goal, unit.civ); if (city) { @@ -471,7 +471,7 @@ export const airMethods = { if (damage.size === 0) return; const destroyed = []; for (const [target, amount] of damage) { - if (this._applyStrikeDamage(target, amount, null)) destroyed.push(target); + if (this._applyStrikeDamage(target, amount)) destroyed.push(target); } this._emitChanged(); }, diff --git a/shared/game_state/economy.js b/shared/game_state/economy.js index aa0eb71..3951f6a 100644 --- a/shared/game_state/economy.js +++ b/shared/game_state/economy.js @@ -133,7 +133,7 @@ export const economyMethods = { const id = this.tileImprovements.get(k); const proto = id ? tileImprovementById(id) : null; if (isResourceTileBuilding(proto)) { - daily += (proto.outputPerDay || 0) * this.getResourcePrice(proto.resource); + daily += this.resourceBuildingOutputAt(k, proto) * this.getResourcePrice(proto.resource); } } return daily * DAYS_PER_YEAR; @@ -522,15 +522,6 @@ export const economyMethods = { return groups.sort((a, b) => b.amount - a.amount); }, - _buildingUpkeepSources(civ) { - return this._buildingUpkeepGroups(civ).map((group) => ({ - label: group.label, - kind: "building", - amount: group.amount, - resources: group.resources, - })); - }, - _unitUpkeepSources(civ) { const sources = new Map(); const battle = this._battleUnitIds(); diff --git a/shared/game_state/industry.js b/shared/game_state/industry.js new file mode 100644 index 0000000..08a83a6 --- /dev/null +++ b/shared/game_state/industry.js @@ -0,0 +1,374 @@ +// Production buildings as independent private-sector agents. Every resource +// building -- a steel mill, a mine, a chip foundry, a power plant -- is not +// merely a region's asset: it keeps its own cash from what it sells, watches +// how its own demand moves, and reinvests that cash in upgrading itself. A +// converter makes only what it can sell and the grid pays its plants for the +// power it delivers (see `_settleGridPower`), so no agent is forced to run at +// a permanent loss. +// +// The daily tick rolls each agent's sales into a short window, then lets an +// agent whose recent sales beat its older ones -- or that is selling out -- buy +// the steel and high-tech for its next level and start the work. An upgrade +// raises throughput exponentially but the extra output costs power linearly +// (see RESOURCE_BUILDING_UPGRADE in shared/data/resources.js), and an agent may +// only have one upgrade under way at a time. + +import { key, parseKey } from "../hex.js"; +import { ECONOMY } from "../data/economy.js"; +import { RESOURCE_RULES } from "../data/resources.js"; +import { + RESOURCE_BUILDING_UPGRADE, + resourceBuildingOutputAt, + resourceBuildingInputAt, + resourceBuildingUpgradeMoneyCost, + tileImprovementById, + isResourceTileBuilding, +} from "../data.js"; +import { HOURS_PER_DAY } from "./constants.js"; + +function emptyBuildingAgent() { + return { level: 0, cash: 0, salesToday: 0, sales: [], upgrading: null }; +} + +export const industryMethods = { + // ------------------------------------------------------- agent state -- + + // Every resource building trades: a material producer sells its good, a + // power plant sells the power its grid delivers. + _isProductionAgent(proto) { + return isResourceTileBuilding(proto); + }, + + _ensureBuildingAgent(k) { + if (!this.buildingAgents) this.buildingAgents = new Map(); + let agent = this.buildingAgents.get(k); + if (!agent) { + agent = emptyBuildingAgent(); + this.buildingAgents.set(k, agent); + } + return agent; + }, + + // Drops the agent of a tile whose building is gone. + _removeBuildingAgent(k) { + if (this.buildingAgents) this.buildingAgents.delete(k); + }, + + buildingAgentAt(coords) { + if (!this.buildingAgents) return null; + return this.buildingAgents.get(key(coords.x, coords.y)) || null; + }, + + resourceBuildingLevel(k) { + const agent = this.buildingAgents ? this.buildingAgents.get(k) : null; + return agent ? agent.level : 0; + }, + + // The output per day of the building on `k`, compounded by its level. + resourceBuildingOutputAt(k, proto) { + return resourceBuildingOutputAt(proto, this.resourceBuildingLevel(k)); + }, + + // The operating input of the building on `k` at its level. `field` names the + // proto field to scale (`energyPerDay` or `fuelEnergyPerDay`). + resourceBuildingInputAt(k, proto, field = "energyPerDay") { + return resourceBuildingInputAt(proto, this.resourceBuildingLevel(k), field); + }, + + // ------------------------------------------------------------ money -- + + // A buyer's payment reaches the producing building, into its own cash, in the + // currency of the nation it belongs to whatever currency it was paid in. + _creditBuildingCash(k, code, amount) { + if (!(amount > 0)) return; + const owner = this.tileImprovementOwner.get(k); + if (owner === undefined || owner < 0) return; + const agent = this._ensureBuildingAgent(k); + const value = amount * this.currencyValueOf(code) / this.currencyValue(owner); + agent.cash = (agent.cash || 0) + value; + }, + + // Records that a production agent sold `qty` of its output during the day, + // feeding the demand trend that decides whether it grows. + _recordBuildingSale(k, qty) { + if (!(qty > 0)) return; + const agent = this._ensureBuildingAgent(k); + agent.salesToday = (agent.salesToday || 0) + qty; + }, + + // A payer that draws on one production building's own cash. + _buildingPayer(k) { + const agent = this._ensureBuildingAgent(k); + const payer = { total: 0 }; + payer.charge = (cost) => { + if (!(cost > 0)) return; + // An agent never mints money: a bill it cannot cover leaves it overdrawn, + // to be paid off from the next sales. + agent.cash = (agent.cash || 0) - cost; + payer.total += cost; + }; + return payer; + }, + + // Charges one production building for something the grid delivered to it, + // from its own cash. Like `_buildingPayer`, it may go overdrawn rather than + // fail, so the money a supplier receives is real. + _chargeBuildingCash(k, code, cost) { + if (!(cost > 0)) return; + const owner = this.tileImprovementOwner.get(k); + if (owner === undefined || owner < 0) return; + const agent = this._ensureBuildingAgent(k); + const value = cost * this.currencyValueOf(code) / this.currencyValue(owner); + agent.cash = (agent.cash || 0) - value; + }, + + // Pays a grid's plants for `kwh` of power worth `money`, split by how much + // each contributed, and credits each with the sale its demand trend reads. + _payGridProducers(component, money, kwh) { + const producers = (component && component.producers) || []; + let total = 0; + for (const producer of producers) total += producer.output || 0; + if (!(total > 0)) { + if (money > 0 && component.civ >= 0) this._creditRegions(component.civ, money); + return; + } + const code = this.currencyOf(component.civ).code; + for (const producer of producers) { + const share = (producer.output || 0) / total; + if (money > 0) this._creditBuildingCash(producer.k, code, money * share); + if (kwh > 0) this._recordBuildingSale(producer.k, kwh * share); + } + }, + + // Everything the nation's production buildings hold, in the nation's currency. + getPrivateBuildingCash(civ) { + if (!this.buildingAgents) return 0; + let total = 0; + for (const [k, agent] of this.buildingAgents) { + if (this.tileImprovementOwner.get(k) !== civ) continue; + total += agent.cash || 0; + } + return total; + }, + + // A nation's production agents as plain objects, for the Economy tab's + // separate private-sector row and the tile panel. + getProductionAgents(civ) { + const result = []; + if (!this.buildingAgents) return result; + for (const [k, agent] of this.buildingAgents) { + if (this.tileImprovementOwner.get(k) !== civ) continue; + const id = this.tileImprovements.get(k); + const proto = id ? tileImprovementById(id) : null; + if (!this._isProductionAgent(proto)) continue; + const coords = parseKey(k); + result.push({ + coords: [coords.x, coords.y], + name: proto.name, + resource: proto.resource, + level: agent.level, + cash: agent.cash || 0, + outputPerDay: resourceBuildingOutputAt(proto, agent.level), + upgrading: !!agent.upgrading, + upgradeDaysLeft: agent.upgrading + ? Math.max(0, Math.ceil((agent.upgrading.totalHours - agent.upgrading.elapsedHours) / HOURS_PER_DAY)) + : 0, + }); + } + return result; + }, + + // The steel and high-tech one upgrade level needs, scaled off its money cost + // like any other construction. Only an advanced producer needs the high-tech. + _upgradeMaterialCost(proto, level) { + const money = resourceBuildingUpgradeMoneyCost(proto, level); + return { + steel: money * RESOURCE_RULES.steelPerBudgetEuro, + hightech: proto.advanced ? RESOURCE_RULES.constructionHighTechPerLevel * (level + 1) : 0, + }; + }, + + // How much of its capacity a converter should run at. It runs flat out while + // its store is thin -- so it always keeps up with demand -- and only eases off + // once it is sitting on more stock than it has been selling, making roughly + // what it sells so it never burns power on goods it cannot move. A brand-new + // building has no history and runs flat out. + _converterProductionScale(k, proto, agent) { + const capacity = resourceBuildingOutputAt(proto, agent ? agent.level : 0); + if (!(capacity > 0)) return 1; + const sales = (agent && agent.sales) || []; + let expected = capacity; + if (sales.length > 0) { + const half = Math.max(1, Math.floor(sales.length / 2)); + const recent = sales.slice(-half); + expected = recent.reduce((sum, value) => sum + value, 0) / recent.length; + } + const target = Math.max(capacity, expected * 2); + const store = this.resourceStock ? this.resourceStock.get(k) : null; + const stock = (store || {})[proto.resource] || 0; + if (stock <= target) return 1; + // Overstocked: make only what has been selling, but never switch off. + return Math.max(0.1, Math.min(1, expected / capacity)); + }, + + // What the next level's materials would cost at today's delivered market + // price, in the agent's national currency. The delivered cost folds in a + // nominal two-tile haul, so the quoted bill is not beaten by a distant + // supplier's freight. + _upgradeMaterialValue(civ, materials) { + const value = materials.steel * this._marketUnitCost("steel") + + materials.hightech * this._marketUnitCost("hightech"); + return value / this.currencyValue(civ); + }, + + // ------------------------------------------------------- daily tick -- + + // One hour of every agent's upgrade, and once a day the demand read that may + // start a new one. + _tickIndustry() { + if (!this.buildingAgents || this.buildingAgents.size === 0) return; + for (const [k, agent] of this.buildingAgents) { + if (!agent.upgrading) continue; + agent.upgrading.elapsedHours += 1; + if (agent.upgrading.elapsedHours >= agent.upgrading.totalHours) { + this._completeBuildingUpgrade(k, agent); + } + } + if (this.totalHours % HOURS_PER_DAY === 0) this._tickBuildingDecisions(); + }, + + _tickBuildingDecisions() { + // Roll today's sales into the trend window before reading it. + for (const agent of this.buildingAgents.values()) { + agent.sales.push(agent.salesToday || 0); + if (agent.sales.length > RESOURCE_BUILDING_UPGRADE.salesWindow) agent.sales.shift(); + agent.salesToday = 0; + } + for (const [k, agent] of this.buildingAgents) { + if (agent.upgrading) continue; + if (agent.level >= RESOURCE_BUILDING_UPGRADE.maxLevel) continue; + const id = this.tileImprovements.get(k); + const proto = id ? tileImprovementById(id) : null; + if (!this._isProductionAgent(proto)) continue; + if (!this._buildingDemandGrowing(agent, this.resourceBuildingOutputAt(k, proto))) { + continue; + } + this._startBuildingUpgrade(k, proto, agent); + } + }, + + // Whether the agent should grow: its recent sales have to beat its older ones, + // or it has to be selling out (demand at least matching what it can make). + _buildingDemandGrowing(agent, capacity = 0) { + const sales = agent.sales || []; + if (sales.length < 2) return false; + const half = Math.floor(sales.length / 2); + const older = sales.slice(0, half); + const recent = sales.slice(half); + const average = (list) => list.reduce((sum, value) => sum + value, 0) / list.length; + const before = average(older); + const now = average(recent); + if (now > before * (1 + RESOURCE_BUILDING_UPGRADE.demandGrowth)) return true; + return capacity > 0 && + now >= capacity * RESOURCE_BUILDING_UPGRADE.sellThrough && + now >= before; + }, + + // Commit an agent to its next level: buy the materials from its own stores and + // the reachable market with its own cash, then start the work. Only one + // upgrade may run at a time. + _startBuildingUpgrade(k, proto, agent) { + const owner = this.tileImprovementOwner.get(k); + if (owner === undefined || owner < 0) return false; + const level = agent.level; + const materials = this._upgradeMaterialCost(proto, level); + const quoted = this._upgradeMaterialValue(owner, materials) * + RESOURCE_BUILDING_UPGRADE.cashMargin; + if ((agent.cash || 0) < quoted) return false; + const coords = parseKey(k); + if (this._buildingMaterialAvailability(owner, coords, "steel") < materials.steel) return false; + if (materials.hightech > 0 && + this._buildingMaterialAvailability(owner, coords, "hightech") < materials.hightech) { + return false; + } + if (!this._buyBuildingMaterials(k, coords, owner, materials)) return false; + const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(owner)); + const money = resourceBuildingUpgradeMoneyCost(proto, level); + const totalHours = capacity > 0 + ? Math.max(1, (money / capacity) * this.constructionSpeedMultiplier(owner)) + : 1; + agent.upgrading = { elapsedHours: 0, totalHours, targetLevel: level + 1 }; + this._tileImprovementVersion += 1; + this._emitChanged(); + return true; + }, + + _completeBuildingUpgrade(k, agent) { + agent.level = agent.upgrading ? agent.upgrading.targetLevel : agent.level + 1; + agent.upgrading = null; + // The extra output lifts the region's GDP, so the cached production is stale. + this._gdpPerCapitaCache.clear(); + this._touchModifiers(); + this._tileImprovementVersion += 1; + this._emitChanged(); + }, + + // The amount of `id` an agent could lay hands on for an upgrade: its own + // store for free, the nation's city stores, and the nearby producers' surplus. + _buildingMaterialAvailability(civ, coords, id) { + const k = key(coords.x, coords.y); + let available = (this.resourceStock.get(k) || {})[id] || 0; + for (const city of this.cities) { + if (city.civ !== civ) continue; + available += this.getCityResourceStock(city)[id] || 0; + } + const city = this._nearestCity(civ, coords); + if (!city) return available; + const nodes = this._resourceNodes(); + const allowSea = this._cityMarketAccess(city); + for (const supplier of this._nearbySuppliers(city, id, nodes, allowSea)) { + if (supplier.kind !== "building") continue; + if (key(supplier.coords.x, supplier.coords.y) === k) continue; + available += this._nodeSurplus(supplier, id); + } + return available; + }, + + // Spends the agent's cash on the upgrade's materials: its own store first + // (already its own), then the nation's regions, then the reachable market. + _buyBuildingMaterials(k, coords, civ, materials) { + const own = this._ensureResourceStock(k); + const ownSteel = Math.min(materials.steel, own.steel || 0); + const ownTech = Math.min(materials.hightech, own.hightech || 0); + if (ownSteel > 0) own.steel -= ownSteel; + if (ownTech > 0) own.hightech -= ownTech; + let needSteel = materials.steel - ownSteel; + let needTech = materials.hightech - ownTech; + const payer = this._buildingPayer(k); + if (needSteel > 0) { + needSteel -= this._drawFromNearest(civ, coords, { steel: needSteel, hightech: 0 }, payer).steel; + } + if (needTech > 0) { + needTech -= this._drawFromNearest(civ, coords, { steel: 0, hightech: needTech }, payer).hightech; + } + if (needSteel > 0 || needTech > 0) { + const city = this._nearestCity(civ, coords); + if (city) { + const nodes = this._resourceNodes(); + const allowSea = this._cityMarketAccess(city); + const haul = { steel: 0, hightech: 0 }; + if (needSteel > 0) { + needSteel -= this._procureFromNeighbours( + city, "steel", needSteel, haul, nodes, { payer, allowSea } + ); + } + if (needTech > 0) { + needTech -= this._procureFromNeighbours( + city, "hightech", needTech, haul, nodes, { payer, allowSea } + ); + } + } + } + return needSteel <= 1e-6 && needTech <= 1e-6; + }, +}; diff --git a/shared/game_state/money.js b/shared/game_state/money.js index c9961d5..9257d37 100644 --- a/shared/game_state/money.js +++ b/shared/game_state/money.js @@ -96,18 +96,6 @@ export const moneyMethods = { // --------------------------------------------------- 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; @@ -302,6 +290,8 @@ export const moneyMethods = { // 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; + // The production buildings are private agents too, holding their own cash. + amount += this.getPrivateBuildingCash(civ); for (const reserves of this.centralBankReserves.values()) { amount += reserves.get(code) || 0; } @@ -405,6 +395,19 @@ export const moneyMethods = { banks.push({ civ: c, name: host ? host.name : "", amount }); bankTotal += amount; } + // The nation's production buildings hold their own cash, in the national + // currency, alongside the regions. + const buildingCash = this.getPrivateBuildingCash(civ); + if (buildingCash !== 0) { + const host = this.civilisations[civ]; + own.push({ + name: "Production buildings", + civ, + civName: host ? host.name : "", + amount: buildingCash, + }); + ownTotal += buildingCash; + } const budget = this.getBudget(civ) || 0; const treasury = Math.max(0, budget); const treasuryDebt = Math.max(0, -budget); diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js index b5ce808..6db4f14 100644 --- a/shared/game_state/politics.js +++ b/shared/game_state/politics.js @@ -378,19 +378,11 @@ export const politicsMethods = { return this.getCivOpinions(civ).approval; }, - getCityPopularity(city) { - return this.getCityOpinions(city).popularity; - }, - getCivPopularity(civ) { return this.getCivOpinions(civ).popularity; }, // What the player sees, in 0..1: the popularity points through the logistic. - getCityPopularityDisplay(city) { - return popularityDisplay(this.getCityPopularity(city)); - }, - getCivPopularityDisplay(civ) { return popularityDisplay(this.getCivPopularity(civ)); }, diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js index 41f81fc..3e46629 100644 --- a/shared/game_state/resources.js +++ b/shared/game_state/resources.js @@ -19,6 +19,7 @@ import { FAMINE, tileImprovementById, isResourceTileBuilding, + resourceById, MONEY, } from "../data.js"; import { @@ -29,9 +30,10 @@ import { luxuryNeedPerDay, foodMonthlyTarget, producerEnergyPerDay, + foodSynthesisEnergy, + synthesisedFoodFor, deliveryEnergyForResource, nextMarketPrice, - upkeepResources, protoUpkeep, constructionResourceCost as sharedConstructionResourceCost, } from "../resources.js"; @@ -314,6 +316,7 @@ export const resourceMethods = { this.tileImprovements.set(k, proto.id); this.tileImprovementOwner.set(k, civ); this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[proto.id] || 1000); + if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k); // 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); @@ -375,7 +378,7 @@ export const resourceMethods = { 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; + made[proto.resource] = (made[proto.resource] || 0) + this.resourceBuildingOutputAt(k, proto); } return made; }, @@ -589,10 +592,17 @@ export const resourceMethods = { 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; + if (proto.resource === "energy") { + energySupply += this.resourceBuildingOutputAt(k, proto); + } else { + // A converter makes only what it can sell, so report the throttled + // figure the daily tick used rather than the nameplate capacity. + const throttle = this._converterScales ? (this._converterScales.get(k) ?? 1) : 1; + production[proto.resource] += this.resourceBuildingOutputAt(k, proto) * throttle; + } } - // The land's harvest is part of what the nation produces. + // The land's harvest is part of what the nation produces, and so is the + // extra food its regions force from energy when the harvest falls short. const foodMultipliers = new Map(); for (const coords of this._territoryByCiv.get(civ) || []) { const k = key(coords.x, coords.y); @@ -608,6 +618,12 @@ export const resourceMethods = { } production.food += tileFoodOutput(this.tiles[k]) * multiplier; } + if (this._foodSynthesis) { + for (const city of this.cities) { + if (city.civ !== civ) continue; + production.food += this._foodSynthesis.get(city.id) || 0; + } + } 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 @@ -1198,6 +1214,9 @@ export const resourceMethods = { // charged to the tile's grid component. const pendingFood = []; const foodMultipliers = new Map(); + // The land's harvest per region, so the synthesis pass below knows who is + // short of what its people eat. + const regionFood = new Map(); for (const k in this.tiles) { const coords = parseKey(k); if (!this._isLand(coords)) continue; @@ -1212,6 +1231,7 @@ export const resourceMethods = { } const produced = tileFoodOutput(this.tiles[k]) * foodMultiplier; if (produced <= 0) continue; + regionFood.set(city.id, (regionFood.get(city.id) || 0) + produced); pendingFood.push({ cityId: city.id, coords: city.coords, @@ -1219,7 +1239,50 @@ export const resourceMethods = { amount: produced, }); const component = ledger.get(components.get(k)); - if (component) component.foodEnergy += producerEnergyPerDay("food", produced); + if (component) { + const foodPower = producerEnergyPerDay("food", produced); + component.foodEnergy += foodPower; + component.foodConsumers.push({ city, amount: foodPower }); + } + } + + // A region whose own harvest falls short of what its people eat can force + // extra food from energy -- greenhouses and synthesis. Growing is cheapest + // for the first tonnes, so the region grows while the marginal energy for + // another tonne beats buying, then completes with the dearer option: what + // the reachable market cannot actually supply, it grows itself. + const foodPrice = this.getResourcePrice("food"); + const energyPrice = this.getResourcePrice("energy"); + const nodes = this._resourceNodes(); + this._foodSynthesis = new Map(); + for (const city of this.cities) { + const produced = regionFood.get(city.id) || 0; + const need = foodNeedPerDay(this.getCityEconomy(city).population); + const shortfall = need - produced; + if (!(shortfall > 0)) continue; + const allowSea = this._cityMarketAccess(city) && + !this._isEncircled(city.coords, city.civ); + // The food the region can actually buy: the reachable surplus. A distant + // island reaches none, so it must grow every tonne itself. + let buyable = 0; + for (const supplier of this._nearbySuppliers(city, "food", nodes, allowSea)) { + buyable += this._nodeSurplus(supplier, "food"); + } + const extra = synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable); + if (!(extra > 0)) continue; + const componentId = components.get(key(city.coords.x, city.coords.y)); + const component = ledger.get(componentId); + if (!component) continue; + const energy = foodSynthesisEnergy(extra); + component.foodEnergy += energy; + component.foodConsumers.push({ city, amount: energy }); + pendingFood.push({ + cityId: city.id, + coords: city.coords, + component: componentId, + amount: extra, + }); + this._foodSynthesis.set(city.id, extra); } // Settle every connected grid: what the plants make against what the cities @@ -1234,27 +1297,32 @@ export const resourceMethods = { for (const component of ledger.values()) { if (component.civ < 0) continue; component.available = component.supply + component.imports; - 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) - ); + // Cities are served first, then the food the region grows, then the + // converters: a famine is worse than an idle factory, so the power that + // feeds people is never throttled away by industry. + const cityServed = Math.min(component.cityEnergy, component.available); component.cityShortage = component.cityEnergy > 0 ? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy)) : 0; + const afterCity = Math.max(0, component.available - cityServed); + const foodServed = Math.min(component.foodEnergy, afterCity); + component.foodScale = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 1; + const afterFood = afterCity - foodServed; + component.industrialScale = component.converterEnergy > 0 + ? Math.max(0, Math.min(1, afterFood / component.converterEnergy)) + : 1; market.get("energy").supply += component.supply; market.get("energy").demand += component.demand; } // A grid that cannot power its industry names the works left idle. this._noteGridPowerShortages(ledger, components); - // Food lands in the region's store, scaled by the power the grid could give. + // Food lands in the region's store, scaled by the power the grid could give + // it. Food is served before the converters, so the harvest survives a + // power squeeze that idles the factories. for (const entry of pendingFood) { const component = ledger.get(entry.component); - const scale = component ? component.industrialScale : 1; + const scale = component ? component.foodScale : 1; const produced = entry.amount * scale; if (produced <= 0) continue; this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced; @@ -1268,7 +1336,8 @@ export const resourceMethods = { 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; + const throttle = this._converterScales ? (this._converterScales.get(k) ?? 1) : 1; + const produced = this.resourceBuildingOutputAt(k, proto) * throttle * scale; if (produced <= 0) continue; this._ensureResourceStock(k)[proto.resource] += produced; market.get(proto.resource).supply += produced; @@ -1276,7 +1345,11 @@ export const resourceMethods = { // The people eat, wear and spend. Shortfalls are matched against reachable // stores -- home regions first, then foreign ones. - this._consumeCityResources(components, market); + this._consumeCityResources(components, market, nodes); + + // Power is a paid private flow: the grid's consumers settle with its plants, + // after the day's sales so an agent can pay from what it just earned. + this._settleGridPower(ledger); // A region that could not feed its people is in famine: it tells the world, // and its people start dying. @@ -1341,56 +1414,98 @@ export const resourceMethods = { // supply, filled in as the world is walked. _buildEnergyLedger(components) { const ledger = new Map(); + // Each converter's production scale for the day, so its power draw matches + // what it actually intends to make. Read again by the production loop. + this._converterScales = new Map(); for (const [k, id] of components) { if (ledger.has(id)) continue; ledger.set(id, { civ: this.territory.get(k), supply: 0, + // The draw that makes up the physical balance: cities and their + // buildings, the converters and the land's food, plus the works' wear + // and the plants' own fuel as internal losses. cityEnergy: 0, converterEnergy: 0, foodEnergy: 0, + fuelEnergy: 0, + // The part of `cityEnergy` the regions actually pay for, so the works' + // and plants' internal losses are not billed to them. + billableCityEnergy: 0, demand: 0, available: 0, industrialScale: 1, + foodScale: 1, cityShortage: 0, + // Who makes the power and who draws it, so the grid's bill can be + // settled between them: the plants sell, the converters pay from their + // own cash and the regions pay for their cities and their food. + producers: [], + cities: [], + converters: [], + foodConsumers: [], + imports: 0, + exports: 0, + importCost: 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); + let amount = 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; + if (proto) amount += this.buildingUpkeepResources(proto, level).energy; } + component.cityEnergy += amount; + component.billableCityEnergy += amount; + component.cities.push({ city, amount }); } 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; + if (isResourceTileBuilding(proto)) { + if (proto.resource === "energy") { + const output = this.resourceBuildingOutputAt(k, proto); + component.supply += output; + component.producers.push({ k, output }); + } else { + const agent = this.buildingAgents ? this.buildingAgents.get(k) : null; + const scale = this._converterProductionScale(k, proto, agent); + this._converterScales.set(k, scale); + const amount = this.resourceBuildingInputAt(k, proto, "energyPerDay") * scale; + component.converterEnergy += amount; + component.converters.push({ k, amount }); + } + if (proto.fuelEnergyPerDay) { + const fuel = this.resourceBuildingInputAt(k, proto, "fuelEnergyPerDay"); + component.fuelEnergy += fuel; + component.cityEnergy += fuel; + } } + // Every work's operating power is a draw on the grid; it rides with the + // cities and is not billed back to a region. component.cityEnergy += protoUpkeep(proto, proto.buildCost || 0).energy; - if (proto.fuelEnergyPerDay) component.cityEnergy += proto.fuelEnergyPerDay; } return ledger; }, // Moves power between grids. A grid in deficit buys from the reachable grids // that hold a surplus, cheapest route first; power is a flow with no store, so - // it flows the same day. Each importer's treasury pays the exporter, whose - // regions receive the money. + // it flows the same day. The seller's plants are paid for the power, and the + // buyer's consumers carry the cost. No government is involved. _tradeGridPower(ledger, components) { const price = this.getResourcePrice("energy"); const surplus = new Map(); for (const [id, component] of ledger) { + component.imports = 0; + component.exports = 0; + component.importCost = 0; surplus.set(id, Math.max(0, component.supply - component.demand)); } for (const [id, component] of ledger) { @@ -1404,31 +1519,70 @@ export const resourceMethods = { .filter(([sid]) => sid !== id && (surplus.get(sid) || 0) > 0) .sort((a, b) => a[1] - b[1]); let needed = deficit; - let spent = 0; for (const [sid, routeEnergy] of suppliers) { if (needed <= 0) break; const available = surplus.get(sid) || 0; const take = Math.min(needed, available); if (!(take > 0)) continue; - // The power costs its price plus the energy burned carrying it. - const cost = take * (price + routeEnergy * price); + // The power costs its price plus the energy burned carrying it. A unit + // of energy weighs `tonnesPerUnit`, so the freight is charged on the + // real tonnage moved, not as if every kWh were a tonne. + const tonnes = take * (resourceById("energy")?.tonnesPerUnit || 1); + const cost = take * price + tonnes * routeEnergy * price; const seller = ledger.get(sid); - this.budgets.set(component.civ, this.getBudget(component.civ) - cost); - this._recordBudgetCash(component.civ, "energy", -cost); - this._recordBudgetCategoryResource(component.civ, "energy", "energy", cost); + this._payGridProducers(seller, cost, take); if (seller.civ !== component.civ) { - this._creditRegions(seller.civ, cost); this._recordExternalBuy(component.civ, "energy", take, cost); this._recordExternalSell(seller.civ, "energy", take, cost); - } else { - this._creditRegions(seller.civ, cost); } surplus.set(sid, available - take); component.imports += take; + component.importCost += cost; + seller.exports += take; needed -= take; - spent += cost; } - if (spent > 0) this._recordResourceSpend(component.civ, "energy", spent, "energy"); + } + }, + + // Settles one grid's power bill: the regions pay for their cities' and their + // food's draw, the converters pay for their own from their cash, and the + // plants are paid for what was actually delivered. The works' wear and the + // plants' fuel are the grid's internal losses, folded into the price. + _settleGridPower(ledger) { + const price = this.getResourcePrice("energy"); + for (const component of ledger.values()) { + if (component.civ < 0) continue; + const cityServed = component.cityEnergy * (1 - (component.cityShortage || 0)); + const billableCityServed = Math.min(cityServed, component.billableCityEnergy); + const foodServed = component.foodEnergy * (component.foodScale ?? 1); + const converterServed = component.converterEnergy * (component.industrialScale ?? 1); + const billableServed = billableCityServed + foodServed + converterServed; + if (!(billableServed > 0)) continue; + const totalConsumed = cityServed + foodServed + converterServed; + const ownUsed = Math.min(component.supply, totalConsumed); + const bill = ownUsed * price + (component.importCost || 0); + const unitPrice = bill / billableServed; + const code = this.currencyOf(component.civ).code; + const cityFactor = component.billableCityEnergy > 0 + ? billableCityServed / component.billableCityEnergy + : 0; + const foodFactor = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 0; + const converterFactor = component.converterEnergy > 0 + ? converterServed / component.converterEnergy + : 0; + for (const entry of component.cities || []) { + const cost = entry.amount * cityFactor * unitPrice; + if (cost > 0) this._chargeRegionCash(entry.city, code, cost); + } + for (const entry of component.foodConsumers || []) { + const cost = entry.amount * foodFactor * unitPrice; + if (cost > 0) this._chargeRegionCash(entry.city, code, cost); + } + for (const entry of component.converters || []) { + const cost = entry.amount * converterFactor * unitPrice; + if (cost > 0) this._chargeBuildingCash(entry.k, code, cost); + } + this._payGridProducers(component, ownUsed * price, ownUsed); } }, @@ -1486,8 +1640,8 @@ export const resourceMethods = { return reach; }, - _consumeCityResources(components, market) { - const nodes = this._resourceNodes(); + _consumeCityResources(components, market, nodes = null) { + nodes = nodes || this._resourceNodes(); // How much of each supplier's surplus earlier buyers have already claimed // today, so a contested surplus goes to whoever bids highest. this._nodeClaims = new Map(); @@ -1679,6 +1833,10 @@ export const resourceMethods = { supplier.stock[id] -= bought; cityStore[id] += bought; remaining -= bought; + // A production building's sale feeds the demand trend it grows on. + if (supplier.kind === "building") { + this._recordBuildingSale(key(supplier.coords.x, supplier.coords.y), bought); + } this.resourceLinks.push({ from: [supplier.coords.x, supplier.coords.y], to: [city.coords.x, city.coords.y], @@ -1689,9 +1847,15 @@ export const resourceMethods = { return amount - remaining; }, - // Pays the region that owns a surplus, into its private-sector cash. + // Pays the producer that owns a surplus. A resource building is an + // independent private-sector agent, so its goods are paid into its own cash; + // a city's surplus is paid to the region that stores it. _paySupplier(node, code, amount) { if (!(amount > 0)) return; + if (node.kind === "building") { + this._creditBuildingCash(key(node.coords.x, node.coords.y), code, amount); + return; + } const city = node.city || this._regionCityNear(node.civ, node.coords); if (city) this._creditRegionCash(city, code, amount); }, @@ -1725,17 +1889,6 @@ export const resourceMethods = { return best; }, - // The capital, or the nation's first city when it has no capital. - _capitalCity(civ) { - let first = null; - for (const city of this.cities) { - if (city.civ !== civ) continue; - if (city.isCapital) return city; - if (!first) first = city; - } - return first; - }, - // The market's delivered price per unit: the good plus the energy a nominal // two-tile road delivery burns. Used to value materials, not to source them. _marketUnitCost(id) { diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js index c276191..7b90a3d 100644 --- a/shared/game_state/serialization.js +++ b/shared/game_state/serialization.js @@ -157,6 +157,8 @@ export const serializationMethods = { taxes: null, taxTake: { sales: 0, export: 0, import: 0 }, commodityInflation: 0, + privateBuildingCash: 0, + productionBuildings: [], }; } const headline = this._civHeadline(viewerCiv); @@ -197,6 +199,10 @@ export const serializationMethods = { exchangeRates: this._serializeExchangeRates(viewerCiv), centralBank: this._serializeCentralBank(viewerCiv), privateCash: this.getPrivateSectorCash(viewerCiv), + // Production buildings are private-sector agents too, but a separate + // class from the regions: they keep their own cash, level and upgrade. + privateBuildingCash: this.getPrivateBuildingCash(viewerCiv), + productionBuildings: this.getProductionAgents(viewerCiv), // Where every unit of the viewer's currency is stored: its treasury, its // regions, and foreign regions and central banks that hold it. currencyHoldings: this.getCurrencyHoldings(viewerCiv), @@ -334,6 +340,7 @@ export const serializationMethods = { id, this.tileImprovementOwner.get(k) ?? -1, this.tileImprovementHp.get(k) || 0, + this.resourceBuildingLevel(k), ]); } return result; diff --git a/shared/game_state/statuses.js b/shared/game_state/statuses.js index 42f5a52..05d77f5 100644 --- a/shared/game_state/statuses.js +++ b/shared/game_state/statuses.js @@ -78,50 +78,6 @@ export const statusMethods = { 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. 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(); - const totalCities = this.cities.filter((city) => city.civ === civ).length; - let component = 0; - for (const start of this.landCells) { - const startKey = key(start.x, start.y); - if (byTile.has(startKey) || !this._landPassable(start, civ)) continue; - const id = component++; - byTile.set(startKey, id); - let cities = 0; - let frontier = [start]; - while (frontier.length > 0) { - const next = []; - for (const coords of frontier) { - const city = this.cityAt(coords); - if (city && city.civ === civ) cities += 1; - for (const neighbour of this._neighbours(coords)) { - const nk = key(neighbour.x, neighbour.y); - if (byTile.has(nk) || !this._landPassable(neighbour, civ)) continue; - byTile.set(nk, id); - next.push(neighbour); - } - } - frontier = next; - } - cityCountByComp.set(id, cities); - } - return { byTile, cityCountByComp, totalCities }; - }, - - _landPassable(coords, civ) { - const tile = this.tiles[key(coords.x, coords.y)]; - if (!tile || tile.terrainClass !== "Land") return false; - if (this.unitsAt(coords).some((unit) => unit.civ !== civ)) return false; - const city = this.cityAt(coords); - if (city && city.civ !== civ) return false; - return true; - }, - _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 78bbdc2..efcda9e 100644 --- a/shared/game_state/warfare.js +++ b/shared/game_state/warfare.js @@ -4,7 +4,7 @@ import { key, parseKey } from "../hex.js"; import { ECONOMY } from "../data/economy.js"; -import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js"; +import { 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"; @@ -54,7 +54,7 @@ export const warfareMethods = { if (!tile || tile.terrainClass !== "Land") return false; if (this.civAt(coords) !== civ) return false; if (this.tileImprovements.has(k)) return false; - if (proto.coastal && !this._isCoastalTile(coords)) return false; + if (proto.coastal && !this.isCoastalCoords(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; @@ -89,6 +89,7 @@ export const warfareMethods = { if (this.tileImprovements.delete(k)) { this.tileImprovementOwner.delete(k); this.tileImprovementHp.delete(k); + this._removeBuildingAgent(k); removed = true; } if (!removed) return false; @@ -128,26 +129,13 @@ export const warfareMethods = { this.tileImprovements.set(k, work.id); this.tileImprovementOwner.set(k, civ); this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000); + if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k); this._tileImprovementVersion += 1; this._gdpPerCapitaCache.delete(k); this._visibilityDirty = true; this._emitChanged(); }, - // Whether a land tile touches the sea, so a coastal cannon may be raised. - _isCoastalTile(coords) { - for (const neighbour of this._neighbours(coords)) { - const tile = this.tiles[key(neighbour.x, neighbour.y)]; - if (tile && tile.terrainClass === "Sea") return true; - } - return false; - }, - - _tileImprovementAt(coords) { - const id = this.tileImprovements.get(key(coords.x, coords.y)); - return id ? tileImprovementById(id) : null; - }, - // The extra defense a tile's fortifications and trenches give a defender, // against a melee or artillery attacker. _tileFortificationMultiplier(coords, attackerProto) { @@ -193,6 +181,7 @@ export const warfareMethods = { this.tileImprovements.delete(k); this.tileImprovementOwner.delete(k); this.tileImprovementHp.delete(k); + this._removeBuildingAgent(k); this._tileImprovementVersion += 1; this._emitChanged(); return true; @@ -285,7 +274,9 @@ export const warfareMethods = { // Finishes any boarding: at zero hours the infantry leave the map and become // the launch's cargo, and are carried until put ashore. _tickEmbark() { - for (const unit of this.units) { + // Iterate a copy: boarding destroys the infantry, and splicing the live + // array would skip whatever unit follows them. + for (const unit of this.units.slice()) { if (!unit.embarking) continue; unit.embarking.hoursLeft -= 1; if (unit.embarking.hoursLeft > 0) continue; diff --git a/shared/game_state/world.js b/shared/game_state/world.js index c281272..e95a9f0 100644 --- a/shared/game_state/world.js +++ b/shared/game_state/world.js @@ -4,7 +4,6 @@ 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"; import { MapTopology, key, parseKey } from "../hex.js"; diff --git a/shared/resources.js b/shared/resources.js index 3a79bcf..1dd0c52 100644 --- a/shared/resources.js +++ b/shared/resources.js @@ -80,6 +80,41 @@ export function foodMonthlyTarget(population) { RESOURCE_RULES.foodMonthlyBuffer; } +// The energy a region spends to force `extraFood` tonnes a day from the land +// beyond its harvest. Quadratic in the extra food, so the marginal tonne gets +// dearer the harder the land is pushed. +export function foodSynthesisEnergy(extraFood) { + const x = Math.max(0, extraFood); + return RESOURCE_RULES.foodSynthesisQuadratic * x * x; +} + +// The marginal energy for one more tonne of synthesised food, the slope of +// `foodSynthesisEnergy`. +export function foodSynthesisMarginalEnergy(extraFood) { + return 2 * RESOURCE_RULES.foodSynthesisQuadratic * Math.max(0, extraFood); +} + +// How much extra food a region grows for a `shortfall` it must cover. Growing +// is always cheapest for the first tonnes (its marginal energy starts near +// zero) and buying is a flat price, so the region grows while the marginal +// energy is cheaper than the market, up to the break-even. Then it completes +// with the dearer option: what the reachable market cannot actually supply -- +// `buyable` tonnes -- it must grow itself, even past the break-even. With an +// unreachable or bottomless market the default `buyable = Infinity` gives the +// plain break-even result. +export function synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable = Infinity) { + const gap = Math.max(0, shortfall); + if (!(gap > 0)) return 0; + const forced = Math.max(0, gap - Math.max(0, buyable)); + const quadratic = RESOURCE_RULES.foodSynthesisQuadratic; + if (!(energyPrice > 0) || !(quadratic > 0) || !(foodPrice > 0)) { + return Math.min(gap, forced); + } + const breakEven = foodPrice / (2 * quadratic * energyPrice); + if (!(breakEven > 0)) return Math.min(gap, forced); + return Math.min(gap, Math.max(breakEven, forced)); +} + // 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; @@ -200,15 +235,6 @@ export function deliveryEnergyForResource(id, amount, tiles, mode, tileMovementC 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 diff --git a/shared/rules.js b/shared/rules.js index 043247f..7ce316a 100644 --- a/shared/rules.js +++ b/shared/rules.js @@ -15,12 +15,6 @@ export function popularityDisplay(points) { return 1 / (1 + Math.exp(-POPULARITY.logisticScale * points)); } -// The hourly pace at which a population at `points` returns to neutral: one -// point an hour, so the estimate is simply the distance left. -export function hoursToNeutral(points) { - return Math.abs(points); -} - // Fixed cost of raising a building to `level` (0-indexed: the cost of the next // level). It grows only with the level, never with the nation's GDP. export function buildingBuildCost(proto, level) { diff --git a/tests/air_test.js b/tests/air_test.js index 28d3567..66f8229 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, RESOURCE_RULES } from "../shared/data.js"; +import { RESOURCE_RULES } from "../shared/data.js"; const BIG_BUDGET = 1.0e15; diff --git a/tests/food_synthesis_test.js b/tests/food_synthesis_test.js new file mode 100644 index 0000000..210d7da --- /dev/null +++ b/tests/food_synthesis_test.js @@ -0,0 +1,121 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState, cityOf } from "./framework/helpers.js"; +import { key } from "../shared/hex.js"; +import { RESOURCE_RULES } from "../shared/data.js"; +import { + foodNeedPerDay, + tileFoodOutput, + foodSynthesisEnergy, + foodSynthesisMarginalEnergy, + synthesisedFoodFor, +} from "../shared/resources.js"; + +const Q = RESOURCE_RULES.foodSynthesisQuadratic; + +// The land's daily harvest in one region, exactly as the daily tick sums it. +function regionFood(state, city) { + let total = 0; + for (const coords of state.regionTiles(city)) { + const k = key(coords.x, coords.y); + if ((state.tilePopulation.get(k) || 0) <= 0) continue; + total += tileFoodOutput(state.tiles[k]) * state._regionFoodMultiplier(city); + } + return total; +} + +export class FoodSynthesisMathTest extends TestCase { + test_energy_for_extra_food_is_quadratic() { + this.assertApprox(foodSynthesisEnergy(0), 0); + this.assertApprox(foodSynthesisEnergy(10), Q * 100); + this.assertApprox(foodSynthesisEnergy(20), 4 * foodSynthesisEnergy(10)); + } + + test_the_marginal_tonne_costs_more_than_the_last() { + this.assertApprox(foodSynthesisMarginalEnergy(0), 0); + this.assertApprox(foodSynthesisMarginalEnergy(10), 2 * Q * 10); + this.assertGreater( + foodSynthesisMarginalEnergy(20), foodSynthesisMarginalEnergy(10), + "diminishing returns: the next tonne costs more" + ); + } + + test_a_region_grows_until_energy_meets_the_market_price() { + const foodPrice = 5100; + const energyPrice = 0.051; + const breakEven = foodPrice / (2 * Q * energyPrice); + this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, 1e6), breakEven); + // A shortfall smaller than the break-even caps the extra food. + this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, 10), 10); + // Cheap energy grows past a larger shortfall; dear energy grows almost none. + this.assertGreater(synthesisedFoodFor(foodPrice, 0.0001, 1e6), breakEven); + this.assertApprox( + synthesisedFoodFor(foodPrice, 1000, 1e6), foodPrice / (2 * Q * 1000) + ); + this.assertApprox(synthesisedFoodFor(foodPrice, 1e9, 1e6), foodPrice / (2 * Q * 1e9)); + } + + test_no_synthesis_without_a_price_or_a_shortfall() { + this.assertApprox(synthesisedFoodFor(5100, 0, 1000), 0); + this.assertApprox(synthesisedFoodFor(0, 0.05, 1000), 0); + this.assertApprox(synthesisedFoodFor(5100, 0.05, 0), 0); + this.assertApprox(synthesisedFoodFor(5100, 0.05, -10), 0); + } + + test_the_region_grows_whatever_the_market_cannot_supply() { + const foodPrice = 5100; + const energyPrice = 0.051; + const shortfall = 10_000; + const breakEven = foodPrice / (2 * Q * energyPrice); + // No reachable supplier: grow the whole shortfall. + this.assertApprox(synthesisedFoodFor(foodPrice, energyPrice, shortfall, 0), shortfall); + // A market that covers only part: grow the rest beyond the break-even. + this.assertApprox( + synthesisedFoodFor(foodPrice, energyPrice, shortfall, 4000), + shortfall - 4000 + ); + // A market that covers the affordable portion: grow only the break-even. + this.assertApprox( + synthesisedFoodFor(foodPrice, energyPrice, shortfall, 9000), + breakEven + ); + this.assertGreater(breakEven, 0); + } +} + +export class FoodSynthesisModelTest extends TestCase { + test_a_region_short_of_food_forces_extra_from_energy() { + const state = smallState(); + const city = cityOf(state, 0); + const shortfall = foodNeedPerDay(state.getCityEconomy(city).population) - + regionFood(state, city); + this.assertGreater(shortfall, 0, "the fixture region opens short of food"); + // Cheap energy against a dear market: force as much as the shortfall allows. + state.setResourcePrice("energy", 0.00001); + state.setResourcePrice("food", 1e9); + state._tickResources(); + const extra = state._foodSynthesis.get(city.id) || 0; + this.assertApprox(extra, shortfall, 1, "the whole shortfall is grown"); + } + + test_a_region_buys_instead_when_energy_is_dearer_than_food() { + const state = smallState(); + const city = cityOf(state, 0); + state.setResourcePrice("energy", 1e9); + state.setResourcePrice("food", 1); + state._tickResources(); + this.assertApprox(state._foodSynthesis.get(city.id) || 0, 0); + } + + test_the_extra_food_lands_in_the_region_s_store() { + const state = smallState(); + const city = cityOf(state, 0); + state.setResourcePrice("energy", 0.00001); + state.setResourcePrice("food", 1e9); + state._tickResources(); + const produced = state.getCivResourceSummary(0).production.food; + this.assertGreater( + produced, regionFood(state, city), + "the nation's food production includes the forced extra" + ); + } +} diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index 3886c52..1a6ab04 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -1,8 +1,7 @@ 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, TRANSPORT_IMPROVEMENTS } from "../shared/data.js"; -import { HOURS_PER_DAY } from "../shared/game_state/constants.js"; +import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, TRANSPORT_IMPROVEMENTS } from "../shared/data.js"; import { hoursInNextMonth } from "../shared/game_clock.js"; import { compact } from "../shared/text_format.js"; import { tileImprovementResourceCost } from "../shared/resources.js"; @@ -417,23 +416,47 @@ export class GameScreenTest extends TestCase { } } - async test_unaffordable_air_strike_is_refused_with_a_message() { + async test_air_strike_without_munitions_is_refused_with_a_message() { const env = await setupDom(); try { const network = stubNetwork(); const screen = new GameScreen(network, makeConfig()); screen.enter(); const state = smallState(); - state.budgets.set(0, 0); const home = state.units.find((u) => u.civ === 0); const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); const jet = state._spawnUnit(home.coords, 0, jetProto); const goal = adjacentLand(state, home); state._spawnUnit(goal, 1, state.protoUnits[0]); - screen.onState(state.snapshot(0)); + const snapshot = state.snapshot(0); + // The server charges an air strike in munitions, not money: without the + // steel and high-tech in store the browser must refuse it. + snapshot.viewerStats.resources.stock.steel = 0; + snapshot.viewerStats.resources.stock.hightech = 0; + screen.onState(snapshot); await screen._orderMove([screen._unit(jet.id)], goal, "attack"); - this.assertEqual(network.orders.length, 0, "no order is sent without the budget"); - this.assertEqual(env.$("#confirm-title").text(), "Insufficient budget"); + this.assertEqual(network.orders.length, 0, "no order is sent without the munitions"); + this.assertEqual(env.$("#confirm-title").text(), "Insufficient munitions"); + screen.leave(); + } finally { + teardownDom(env); + } + } + + async test_air_strike_with_munitions_is_allowed() { + const env = await setupDom(); + try { + const screen = new GameScreen(stubNetwork(), makeConfig()); + screen.enter(); + const state = smallState(); + const home = state.units.find((u) => u.civ === 0); + const jetProto = state.protoUnits.find((u) => u.id === "jet_fighter"); + const jet = state._spawnUnit(home.coords, 0, jetProto); + screen.onState(state.snapshot(0)); + this.assertNull( + screen._airStrikeBlocked([screen._unit(jet.id)]), + "full stores arm the aircraft" + ); screen.leave(); } finally { teardownDom(env); diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js index 73e625b..d249a49 100644 --- a/tests/game_state_buildings_test.js +++ b/tests/game_state_buildings_test.js @@ -1,7 +1,7 @@ import { TestCase } from "./framework/test_case.js"; import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/helpers.js"; import { key } from "../shared/hex.js"; -import { EFFECT_RESEARCH, RESOURCE_RULES } from "../shared/data.js"; +import { EFFECT_RESEARCH } from "../shared/data.js"; import { tileFoodOutput } from "../shared/resources.js"; import { DAYS_PER_YEAR } from "../shared/game_state/constants.js"; diff --git a/tests/industry_test.js b/tests/industry_test.js new file mode 100644 index 0000000..e1bbc9d --- /dev/null +++ b/tests/industry_test.js @@ -0,0 +1,248 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState, cityOf } from "./framework/helpers.js"; +import { key } from "../shared/hex.js"; +import { + RESOURCE_BUILDING_UPGRADE, + resourceBuildingOutputAt, + resourceBuildingInputAt, + resourceBuildingUpgradeMoneyCost, + tileImprovementById, +} from "../shared/data.js"; + +// A production building of `id` on the first land tile of `civ`'s that is not a +// city, wired into the agent maps and with an empty stock. The tile may already +// carry a work; it is replaced. +function placeProducer(state, id, civ = 0) { + let coords = null; + for (const candidate of state._territoryByCiv.get(civ) || []) { + if (state.cityAt(candidate)) continue; + if (!state._isLand(candidate)) continue; + if (state.tileImprovements.has(key(candidate.x, candidate.y))) continue; + coords = candidate; + break; + } + 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); + const agent = state._ensureBuildingAgent(k); + return { coords, k, agent }; +} + +// Gives the nation's cities enough stores to cover an upgrade's steel bill. +function fillMaterialStores(state, civ, amount = 1e7) { + for (const city of state.cities) { + if (city.civ !== civ) continue; + state.getCityResourceStock(city).steel = amount; + state.getCityResourceStock(city).hightech = amount; + } +} + +export class ProductionBuildingUpgradeMathTest extends TestCase { + test_output_compounds_exponentially_with_the_level() { + const proto = { outputPerDay: 1000 }; + const alpha = RESOURCE_BUILDING_UPGRADE.alpha; + this.assertApprox(resourceBuildingOutputAt(proto, 0), 1000); + this.assertApprox(resourceBuildingOutputAt(proto, 1), 1000 * alpha); + this.assertApprox(resourceBuildingOutputAt(proto, 2), 1000 * alpha * alpha); + } + + test_input_grows_linearly_with_the_level() { + const proto = { energyPerDay: 500 }; + this.assertApprox(resourceBuildingInputAt(proto, 0, "energyPerDay"), 500); + this.assertApprox(resourceBuildingInputAt(proto, 2, "energyPerDay"), 1500); + this.assertApprox( + resourceBuildingInputAt(proto, 1, "energyPerDay"), + 500 * (1 + RESOURCE_BUILDING_UPGRADE.inputPerLevel) + ); + } + + test_each_upgrade_cost_more_than_the_last() { + const proto = { buildCost: 800_000_000 }; + const first = resourceBuildingUpgradeMoneyCost(proto, 0); + const second = resourceBuildingUpgradeMoneyCost(proto, 1); + this.assertApprox(first, 800_000_000); + this.assertGreater(second, first); + this.assertApprox(second, 800_000_000 * RESOURCE_BUILDING_UPGRADE.costAlpha); + } +} + +export class ProductionBuildingAgentTest extends TestCase { + test_a_production_building_keeps_what_it_sells() { + const state = smallState(); + const { coords, k, agent } = placeProducer(state, "steel_mill"); + const code = state.currencyOf(0).code; + const city = cityOf(state, 0); + const regionBefore = state.getRegionCashValue(city); + state._paySupplier({ kind: "building", coords, civ: 0 }, code, 12_345); + this.assertApprox(agent.cash, 12_345, 1e-6, "the sale reached the building"); + this.assertApprox(state.getRegionCashValue(city), regionBefore, 1e-6, "not the region"); + this.assertEqual(state.getPrivateBuildingCash(0), agent.cash); + } + + test_plants_trade_their_power_like_any_producer() { + const state = smallState(); + const plant = tileImprovementById("coal_power_plant"); + const mill = tileImprovementById("steel_mill"); + this.assertTrue(state._isProductionAgent(plant), "a plant sells power"); + this.assertTrue(state._isProductionAgent(mill), "a mill sells steel"); + } + + test_growth_needs_the_recent_half_of_the_window_to_beat_the_older_half() { + const state = smallState(); + const { agent } = placeProducer(state, "steel_mill"); + agent.sales = [10, 10, 10]; + this.assertFalse(state._buildingDemandGrowing(agent), "flat demand is not growth"); + agent.sales = [20, 20, 20, 30, 30, 30]; + this.assertTrue(state._buildingDemandGrowing(agent), "rising demand is growth"); + agent.sales = [30, 30, 30, 20, 20, 20]; + this.assertFalse(state._buildingDemandGrowing(agent), "falling demand is not growth"); + } + + test_a_building_starts_an_upgrade_and_cannot_start_a_second() { + const state = smallState(); + const { coords, k, agent } = placeProducer(state, "steel_mill"); + agent.cash = 1e12; + agent.sales = [10, 12, 15, 18, 22, 27]; + fillMaterialStores(state, 0); + state._tickBuildingDecisions(); + this.assertNotNull(agent.upgrading, "the upgrade started"); + const job = agent.upgrading; + this.assertEqual(agent.level, 0, "the level waits for the work"); + // A second read may not replace the work already under way. + agent.sales = [100, 200, 300, 400, 500, 600]; + state._tickBuildingDecisions(); + this.assertEqual(agent.upgrading, job, "only one upgrade at a time"); + } + + test_spending_on_an_upgrade_consumes_cash_and_materials() { + const state = smallState(); + const { agent, k } = placeProducer(state, "steel_mill"); + agent.cash = 1e12; + agent.sales = [10, 12, 15, 18, 22, 27]; + fillMaterialStores(state, 0); + const before = agent.cash; + state._tickBuildingDecisions(); + this.assertLess(agent.cash, before, "the agent paid for its materials"); + this.assertGreater(state.getCityResourceStock(cityOf(state, 0)).steel, 0, "stores left over"); + this.assertNotNull(tileImprovementById(state.tileImprovements.get(k))); + } + + test_completing_an_upgrade_raises_the_level_and_its_output() { + const state = smallState(); + const { k, agent } = placeProducer(state, "steel_mill"); + const proto = tileImprovementById("steel_mill"); + agent.cash = 1e12; + agent.sales = [10, 12, 15, 18, 22, 27]; + fillMaterialStores(state, 0); + const baseOutput = state.resourceBuildingOutputAt(k, proto); + // Keep the day-boundary decision from firing while the work is completed. + state.totalHours = 1; + state._tickBuildingDecisions(); + this.assertNotNull(agent.upgrading); + agent.upgrading.totalHours = 0; + state._tickIndustry(); + this.assertEqual(agent.level, 1, "the level arrived"); + this.assertNull(agent.upgrading, "the work is done"); + this.assertGreater( + state.resourceBuildingOutputAt(k, proto), baseOutput, + "the upgraded building makes more" + ); + } + + test_an_agent_without_enough_cash_waits() { + const state = smallState(); + const { agent } = placeProducer(state, "steel_mill"); + agent.cash = 0; + agent.sales = [10, 12, 15, 18, 22, 27]; + fillMaterialStores(state, 0); + state._tickBuildingDecisions(); + this.assertNull(agent.upgrading, "no cash, no upgrade"); + } + + test_the_snapshot_ships_the_production_agents_apart_from_the_regions() { + const state = smallState(); + const { coords, agent } = placeProducer(state, "steel_mill"); + agent.cash = 4_000_000; + const stats = state.snapshot(0).viewerStats; + this.assertApprox(stats.privateBuildingCash, state.getPrivateBuildingCash(0)); + const entry = stats.productionBuildings.find( + (item) => item.coords[0] === coords.x && item.coords[1] === coords.y + ); + this.assertNotNull(entry, "the building is listed on its own row"); + this.assertEqual(entry.level, 0); + this.assertApprox(entry.cash, 4_000_000, 1e-6); + } +} + +export class PowerMarketTest extends TestCase { + test_a_grid_pays_its_plants_for_the_power_it_delivers() { + const state = smallState(); + const plant = placeProducer(state, "coal_power_plant"); + const converter = placeProducer(state, "steel_mill"); + const city = cityOf(state, 0); + const plantBefore = plant.agent.cash; + const converterBefore = converter.agent.cash; + const regionBefore = state.getRegionCashValue(city); + const price = state.getResourcePrice("energy"); + const ledger = new Map([[0, { + civ: 0, + supply: 1000, + cityEnergy: 100, + converterEnergy: 200, + foodEnergy: 0, + billableCityEnergy: 100, + cityShortage: 0, + industrialScale: 1, + producers: [{ k: plant.k, output: 1000 }], + cities: [{ city, amount: 100 }], + converters: [{ k: converter.k, amount: 200 }], + foodConsumers: [], + imports: 0, + importCost: 0, + }]]); + state._settleGridPower(ledger); + this.assertApprox(plant.agent.cash - plantBefore, 300 * price, 1e-6, "the plant is paid"); + this.assertApprox(converter.agent.cash - converterBefore, -200 * price, 1e-6, "the converter pays"); + this.assertApprox(state.getRegionCashValue(city) - regionBefore, -100 * price, 1e-6, + "the region pays"); + this.assertApprox(plant.agent.salesToday, 300, 1e-6, "the sale feeds the demand trend"); + } + + test_imported_power_is_paid_to_the_exporting_grid_s_plants() { + const state = smallState(); + const plant = placeProducer(state, "coal_power_plant"); + const component = { civ: 0, producers: [{ k: plant.k, output: 100 }] }; + const before = plant.agent.cash; + state._payGridProducers(component, 5_000, 40); + this.assertApprox(plant.agent.cash - before, 5_000, 1e-6); + this.assertApprox(plant.agent.salesToday, 40, 1e-6); + } + + test_an_overstocked_converter_throttles_to_what_it_sells() { + const state = smallState(); + const { k, agent } = placeProducer(state, "steel_mill"); + const proto = tileImprovementById("steel_mill"); + this.assertEqual( + state._converterProductionScale(k, proto, agent), 1, + "a building with no history runs flat out" + ); + agent.sales = [4_000, 4_000, 4_000, 4_000]; + state._ensureResourceStock(k).steel = 1e9; + const scale = state._converterProductionScale(k, proto, agent); + this.assertLess(scale, 1, "a full store eases off"); + this.assertApprox(scale, 4_000 / proto.outputPerDay, 1e-3); + } + + test_a_converter_sells_out_and_then_grows() { + const state = smallState(); + const { k, agent } = placeProducer(state, "steel_mill"); + const proto = tileImprovementById("steel_mill"); + agent.sales = new Array(6).fill(proto.outputPerDay); + this.assertTrue( + state._buildingDemandGrowing(agent, proto.outputPerDay), + "selling out is a reason to grow" + ); + } +} diff --git a/tests/money_test.js b/tests/money_test.js index c4b8307..5b7658b 100644 --- a/tests/money_test.js +++ b/tests/money_test.js @@ -1,6 +1,5 @@ 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 { diff --git a/tests/political_map_test.js b/tests/political_map_test.js index fffff84..895f140 100644 --- a/tests/political_map_test.js +++ b/tests/political_map_test.js @@ -1,7 +1,7 @@ import { TestCase } from "./framework/test_case.js"; import { setupDom, teardownDom } from "./framework/dom.js"; import { MapView } from "../client/js/map_view.js"; -import { MapTopology, key, parseKey } from "../shared/hex.js"; +import { MapTopology, key } from "../shared/hex.js"; import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js"; import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js"; import { diff --git a/tests/regression_test.js b/tests/regression_test.js index 30ff611..baf1a17 100644 --- a/tests/regression_test.js +++ b/tests/regression_test.js @@ -9,7 +9,6 @@ import { TECHNOLOGIES, TERRAIN_TILES, MAP_CONFIG, - ECONOMY, TAXES, BATTLE, effectValue, diff --git a/tests/siege_test.js b/tests/siege_test.js index 46b2d12..9dd740f 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, grantBuilding } from "./framework/helpers.js"; -import { parseKey, key } from "../shared/hex.js"; +import { 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"; @@ -232,24 +232,3 @@ function findCoastalClearCity(state, civ) { } return null; } - -// A sea tile with two non-adjacent land neighbours: l1 for the cut-off unit, -// l2 for the coastal destination city. -function findCoastalPair(state) { - for (const k in state.tiles) { - if (state.tiles[k].terrainClass !== "Sea") continue; - const s = parseKey(k); - const land = state.topology.neighbours(s.x, s.y).filter( - (n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n) - ); - for (let i = 0; i < land.length; i++) { - for (let j = i + 1; j < land.length; j++) { - if (state.topology.tileDistance(land[i], land[j]) <= 1) continue; - const neighbours = state.topology.neighbours(land[i].x, land[i].y).filter((n) => state._isLand(n)); - if (neighbours.some((n) => state.unitAt(n) || state.cityAt(n))) continue; - return { s: { x: s.x, y: s.y }, l1: { x: land[i].x, y: land[i].y }, l2: { x: land[j].x, y: land[j].y } }; - } - } - } - return null; -}