From 4600b16399c70afb10d1fdb9afe1f9cd496eae2a Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Wed, 23 Sep 2026 15:59:28 +0200 Subject: [PATCH] Stockpiled a month of every material and cached the trade walks Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss. The delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default). Roadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended. --- AGENTS.md | 25 +++- ROADMAP.md | 13 ++ client/js/devlog_data.js | 12 +- server/game_server.js | 5 + server/server.js | 8 +- shared/data/resources.js | 21 ++- shared/economy_graph.js | 27 ++-- shared/game_state.js | 101 ++++++++++++- shared/game_state/combat.js | 1 + shared/game_state/diplomacy.js | 1 + shared/game_state/industry.js | 37 +++-- shared/game_state/movement.js | 2 +- shared/game_state/resources.js | 265 ++++++++++++++++++++++++++------- shared/game_state/warfare.js | 38 ++++- shared/game_state/world.js | 1 + shared/resources.js | 40 +++-- tests/economy_graph_test.js | 44 ++++++ tests/food_power_test.js | 88 +++++++++++ tests/food_synthesis_test.js | 27 +++- tests/industry_test.js | 31 ++++ tests/money_test.js | 12 +- tests/resources_test.js | 16 +- tests/server_test.js | 4 +- tests/stockpile_test.js | 66 ++++++++ tests/trade_graph_test.js | 36 +++++ 25 files changed, 793 insertions(+), 128 deletions(-) create mode 100644 tests/economy_graph_test.js create mode 100644 tests/food_power_test.js create mode 100644 tests/stockpile_test.js diff --git a/AGENTS.md b/AGENTS.md index c41a529..c3afa60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,10 @@ everything else (transport included) is hand-written. - Start the game server and client locally: `node server/server.js --port 27015 --bind 127.0.0.1`. Then open `http://127.0.0.1:27015/` (redirects to `/client/index.html`). The browser - connects to `ws(s):///ws`. + connects to `ws(s):///ws`. The server settles the world for 150 + days before it listens so the economy opens established; pass `--warmup ` + (or set `WARMUP`) to change that and `--warmup 0` to skip it. The settle logs + its progress and takes tens of seconds on the standard map. - Install the versioned git hooks (runs the tests on every commit): `./scripts/install-git-hooks.sh`; uninstall with `git config --unset core.hooksPath`. @@ -126,6 +129,26 @@ There is no linter, formatter, or CI. Do not invent commands beyond these. a known seed. The snapshot ships `mapConfig` and `MapView.ensureTerrain` rebuilds the exact same terrain from it (not the global `MAP_CONFIG`), which also lets the DOM tests use a small map fixture. +- The economy advances on an hourly tick (`shared/game_state/resources.js` + `_tickResources`). Within one tick the city-market-access and node-population + lookups are cached on `GameState` and dropped at the end, so an order that + lands mid-tick still sees fresh data. The trade graph is the expensive part — a + walk from each city floods the ocean, since a sea lane runs any distance — so + it is kept across ticks *and* days. `_tradeGraph` caches each `(city, mode)` + walk under a stamp built from the road/railway/work/territory version counters, + so any network change rebuilds lazily while a quiet world reuses it; a unit + move only drops it while a war is on (a hostile camp can sever a route). This + is what stops a settled world re-flooding every sea lane each day. + `GameState.warmUp(days)` runs the tick without players and the server calls it + from `configureGame` with the `warmupDays` that `startServer`/the `--warmup` + flag supplies (0 skips it). +- Food is the one good a region synthesises from energy, so its price signal must + come from the reserve, not the day's meal: `_tickResources` grows toward + `need + (stockpileTarget - store) - harvest`, and `_consumeCityResources` books + the empty-pantry appetite (`reserveGap / stockpileDays`) as demand. Without the + reserve term a region lives hand-to-mouth with stores near zero and the food + price stays flat. Keep the two in step: raising one without the other only + forces synthesis or only bids the price, not both. - `shared/` holds the reusable algorithms so they stay testable without a server: `hex.js` (topology/geometry), `map_generator.js`, `hex_pathfinder.js`, `terrain_stats.js`, `rules.js`, `economy` figures in `game_state.js`, diff --git a/ROADMAP.md b/ROADMAP.md index 726d9b6..23b306c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,9 @@ simplification worth revisiting. Anything unticked is still open. ### Delivering resources * [x] Delivering 1 tonne of resources over 1 tile requires 10 MWh by truck over roads, 50 MWh * tile movement cost without roads, 1 MWh by train, 50 kWh by ship. +* Freight is deliberately costly: hauling goods burns far more energy than the + goods themselves are worth over any real distance, so the heavy electricity + load the delivery graph puts on the grid is intended, not a leak to shave. * [x] A delivery graph, revealed only in the economic map mode, is created. * [x] The delivery graph is built * [~] A city always buys from the cheapest source: a nearby store whose @@ -243,6 +246,16 @@ simplification worth revisiting. Anything unticked is still open. ## Open items +- [ ] Faster politics tick: with the trade-graph walks cached, `_tickPolitics` is + the largest remaining cost in a settled world -- `_migrateForIncome` + through `_regionIncomePerCapita` and `_movePopulation`, and the repeated + `_cityMechanicLevel` lookups each of them makes. Memoise the per-region + income the way the trade graph is now kept, and let a migration sweep visit + only the regions whose pull actually changed. +- [ ] Luxury and high-tech prices climb to a plateau far above their base (the + economy simulator's index reaches about 500-650 for luxury and 200-230 for + high-tech) instead of settling; revisit the supply and demand balance of + the two rarer goods. - [ ] A dedicated structure hit-point model for cities and tile improvements (see the `[~]` note above). - [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`, diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 53ce268..1cbeec1 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "3418e80", + "date": "2026-09-23", + "subject": "Turned unit upkeep into a resource bill and gave research the erlenmeyer icon", + "body": "" + }, { "hash": "15d0c38", "date": "2026-09-23", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-22", "subject": "Fixed a typo for Northumbrian towns", "body": "" - }, - { - "hash": "8f9f72a", - "date": "2026-09-22", - "subject": "Added Bulgaria and Northumbria as playable nations", - "body": "Bulgaria (Sofia, Plovdiv, Varna...) and Northumbria (Newcastle upon Tyne, Washington, Gateshead...) join the roster with their own colours, currencies, city-name pools and 128x128 flag art. France and Britain also lead with their capitals now, Paris and London." } ]; diff --git a/server/game_server.js b/server/game_server.js index be26ab6..ed10ad5 100644 --- a/server/game_server.js +++ b/server/game_server.js @@ -107,6 +107,11 @@ export class GameServer extends EventEmitter { this.state.onChanged(() => { this._stateDirty = true; }); + // A fresh world can be settled before the first player joins, so the game + // opens with its economy and reserves already established rather than at a + // cold start. Off by default; the launcher asks for the days it wants. + const warmupDays = Number(setup.warmupDays || 0); + if (warmupDays > 0) this.state.warmUp(warmupDays); this._configured = true; for (const peerId of this.network.getPeerIds()) { this._assignCiv(peerId, this.peerNames.get(peerId) || ""); diff --git a/server/server.js b/server/server.js index 6a6a5f0..6207b73 100644 --- a/server/server.js +++ b/server/server.js @@ -111,6 +111,7 @@ export function startServer({ testing = false, civilisations = [], mapConfig = null, + warmupDays = 0, } = {}) { const httpServer = createServer(serveStatic); const wsServer = new WebSocketServer(httpServer, { path: "/ws" }); @@ -127,7 +128,9 @@ export function startServer({ }; const gameServer = new GameServer(network); - gameServer.configureGame({ seed, player_civ: 0, civilisations, testing, mapConfig }); + if (warmupDays > 0) log(`settling the world for ${warmupDays} days...`); + gameServer.configureGame({ seed, player_civ: 0, civilisations, testing, mapConfig, warmupDays }); + if (warmupDays > 0) log(`world settled at day ${warmupDays}`); function broadcastPlayers() { const players = {}; @@ -265,5 +268,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) // Free, instant orders for local play; remote peers still have their `free` // flag stripped in the order handler. testing: process.argv.includes("--testing"), + // Settle the world this many days before serving, so the game opens with + // its economy established. `--warmup 0` skips it. + warmupDays: Number(argValue("--warmup", process.env.WARMUP || 150)) | 0, }); } diff --git a/shared/data/resources.js b/shared/data/resources.js index 81eca64..51e21a2 100644 --- a/shared/data/resources.js +++ b/shared/data/resources.js @@ -158,9 +158,9 @@ export const RESOURCE_RULES = { steelPerPersonPerDay: 0.001, // Luxuries are rarer: 0.01 carat a person a day. luxuryPerPersonPerDay: 0.01, - // Cities aim to hold 130% of a month's food before the month begins. - foodMonthlyBuffer: 1.3, - daysPerMonth: 30, + // Cities aim to hold a month of every storable resource -- food, steel, + // luxury and high-tech -- before the month begins. + stockpileDays: 30, // A city's baseline electric appetite, in kWh per euro of yearly GDP. This is // the "kWh per euro of GDP" economic metric; the top bar does not show it. @@ -242,6 +242,11 @@ export const RESOURCE_RULES = { unitSupplyDays: 50, starvationHpFractionPerDay: 0.1, + // A region that cannot buy the food it needs bids the price up: every tonne + // of unmet demand counts this many times over in the market, so a local famine + // shows in the national food price instead of hiding behind a balanced total. + famineBidFactor: 3, + // Repairs: damage leaves a materials debt the owner pays down, and only then // does the structure recover. A razed city level costs what it cost to build. repair: { @@ -274,8 +279,10 @@ export const RESOURCE_RULES = { }, }; -// How far a city will look for a neighbour's surplus before falling back to the -// global market. +// How far a city will look overland for a neighbour's surplus. Sea legs are not +// counted: a shipping lane between ports reaches any coast, so a region can +// always trade with a sea-connected one, however far, at the freight's energy +// cost. The cap only keeps a city from trucking goods right across a continent. export const RESOURCE_TRADE_RADIUS = 8; // A region that can reach a food surplus it does not need buys extra to resell @@ -479,7 +486,9 @@ export const RESOURCE_BUILDING_UPGRADE = { 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, + // A long window means only demand that is sustained earns an upgrade, not a + // one-day spike. + salesWindow: 12, 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. diff --git a/shared/economy_graph.js b/shared/economy_graph.js index 6e60442..d7e53b6 100644 --- a/shared/economy_graph.js +++ b/shared/economy_graph.js @@ -14,18 +14,24 @@ import { RESOURCE_RULES, resourceById } from "./data/resources.js"; // Breadth-first spread from `start` over an abstract graph, in rings of // increasing distance. `neighbours(node)` returns the outward edges of a node, -// each `{ node, cost }`; a directed graph is just a `neighbours` that omits the -// reverse edge. The traversal stops at `maxDistance` steps. +// each `{ node, cost, weight }`; a directed graph is just a `neighbours` that +// omits the reverse edge. The traversal stops at `maxDistance` steps, or once +// its accumulated `weight` (one per edge, `edge.weight` or 1) exceeds +// `maxWeight` -- so a caller can bound one kind of leg without bounding all of +// them, e.g. land trade at eight tiles while sea legs run free. // -// `accept(node, { distance, cost })` is called for every newly reached node -// (never the start) and decides whether it joins the result; returning false -// still lets the search continue through it. The result is the accepted nodes -// with their accumulated `distance` (steps) and `cost` (sum of edge costs), -// sorted by cost. -export function traverse(start, { neighbours, id = (node) => node, accept = null, maxDistance = Infinity }) { +// `accept(node, { distance, cost, weight })` is called for every newly reached +// node (never the start) and decides whether it joins the result; returning +// false still lets the search continue through it. The result is the accepted +// nodes with their accumulated `distance` (steps), `cost` (sum of edge costs) +// and `weight`, sorted by cost. +export function traverse(start, { + neighbours, id = (node) => node, accept = null, + maxDistance = Infinity, maxWeight = Infinity, +}) { const results = []; const visited = new Set([id(start)]); - let frontier = [{ node: start, distance: 0, cost: 0 }]; + let frontier = [{ node: start, distance: 0, cost: 0, weight: 0 }]; while (frontier.length > 0) { const next = []; for (const current of frontier) { @@ -33,11 +39,14 @@ export function traverse(start, { neighbours, id = (node) => node, accept = null for (const edge of neighbours(current.node)) { const key = id(edge.node); if (visited.has(key)) continue; + const weight = current.weight + (edge.weight === undefined ? 1 : edge.weight); + if (weight > maxWeight) continue; visited.add(key); const reached = { node: edge.node, distance: current.distance + 1, cost: current.cost + (edge.cost || 0), + weight, }; if (!accept || accept(edge.node, reached)) results.push(reached); next.push(reached); diff --git a/shared/game_state.js b/shared/game_state.js index 804dc5c..8dcb3af 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -53,6 +53,9 @@ import { budgetMethods } from "./game_state/budget.js"; import { moneyMethods } from "./game_state/money.js"; import { taxMethods } from "./game_state/taxes.js"; +// Shared empty result for `unitsAt`, so an empty tile does not allocate. +const EMPTY_UNITS = []; + export { MAX_NEWS, UNIT_VISION, @@ -211,6 +214,23 @@ export class GameState { this._cityNamesUsed = new Map(); this._usedCityNames = new Set(); this._unitById = new Map(); + this._unitsByTile = new Map(); + // Per-civ "tiles held by hostile troops", rebuilt the moment a unit is + // spawned, moved or killed, or a war begins; read many times a day. + this._hostileOccupiedCache = new Map(); + // Per-(city, mode) trade reachability and the port-access memo. The graph is + // expensive to walk (sea lanes run any distance, so a walk floods the ocean), + // so it is kept across the day's sourcing passes *and* across days, and only + // thrown away when the transport network itself changes. `_tradeGraphStamp` + // records which network version the cache holds; roads, railways, storage + // buildings and territory each bump their own counter, so any change picks a + // fresh stamp and rebuilds lazily. Unit movement only matters while a war is + // on (a hostile camp severs a route), see `_invalidateUnitCaches`. + this._tradeGraphCache = null; + this._tradeGraphStamp = null; + this._marketAccessCache = null; + // Per-city populations, read once for the day's surplus checks. + this._nodePopulationCache = null; this._cityById = new Map(); this._cityByCoords = new Map(); this._cityAdjacent = new Map(); @@ -338,13 +358,67 @@ export class GameState { } // Every unit standing on `coords`. Friendly units may stack, so a tile can - // hold more than one. + // hold more than one. Backed by a per-tile index so the many supply, siege and + // battle checks that ask "who is on this tile" never scan the whole army. unitsAt(coords) { - const result = []; - for (const unit of this.units) { - if (unit.coords.x === coords.x && unit.coords.y === coords.y) result.push(unit); + const bucket = this._unitsByTile && this._unitsByTile.get(key(coords.x, coords.y)); + if (bucket) return bucket; + return EMPTY_UNITS; + } + + _indexUnit(unit) { + if (!this._unitsByTile) this._unitsByTile = new Map(); + this._invalidateUnitCaches(); + const k = key(unit.coords.x, unit.coords.y); + let bucket = this._unitsByTile.get(k); + if (!bucket) { + bucket = []; + this._unitsByTile.set(k, bucket); } - return result; + bucket.push(unit); + } + + _unindexUnit(unit) { + if (!this._unitsByTile) return; + this._invalidateUnitCaches(); + const k = key(unit.coords.x, unit.coords.y); + const bucket = this._unitsByTile.get(k); + if (!bucket) return; + const index = bucket.indexOf(unit); + if (index >= 0) bucket.splice(index, 1); + if (bucket.length === 0) this._unitsByTile.delete(k); + } + + // Drops the memos that depend on where units stand: which tiles hostile + // troops hold, the trade routes they sever and whether a port is blockaded. + _invalidateSpatialCaches() { + if (this._hostileOccupiedCache) this._hostileOccupiedCache.clear(); + if (this._tradeGraphCache) { + this._tradeGraphCache.clear(); + this._tradeGraphStamp = null; + } + if (this._marketAccessCache) this._marketAccessCache.clear(); + } + + // The narrower drop for a unit spawning, moving or dying. Hostile-held tiles + // and port access depend on where troops stand, so those memos go. The trade + // graph does not: it only reads unit positions through `_hostileOccupied`, + // which is empty unless a war is on, so a move in peacetime leaves the cached + // walks valid. While a war is on, any move may sever a route, so drop it then. + _invalidateUnitCaches() { + if (this._hostileOccupiedCache) this._hostileOccupiedCache.clear(); + if (this._marketAccessCache) this._marketAccessCache.clear(); + if (this._tradeGraphCache && this.wars && this.wars.size > 0) { + this._tradeGraphCache.clear(); + this._tradeGraphStamp = null; + } + } + + // Moves a unit to a new tile and keeps the per-tile index in step. + _moveUnitTo(unit, coords) { + this._unindexUnit(unit); + unit.coords = coords; + this._indexUnit(unit); } unitAt(coords) { @@ -361,6 +435,23 @@ export class GameState { this.tickHour(); } + // Runs `days` of the world as fast as the machine allows, using the very same + // `advanceHour` the live clock runs, so a fresh game can open settled instead + // of at a cold start. Nothing changes the transport network while the settle + // runs, so the trade-graph cache (see `_tradeGraph`) is simply reused across + // every day; a settle would otherwise re-flood every sea lane daily. + warmUp(days) { + const hours = Math.max(0, Math.floor(days)) * HOURS_PER_DAY; + if (hours <= 0) return; + try { + for (let hour = 0; hour < hours; hour++) this.advanceHour(); + } finally { + this._tradeGraphCache = null; + this._tradeGraphStamp = null; + this._marketAccessCache = null; + } + } + tickHour() { this.totalHours += 1; // Natural growth is a whole number of days, so the cached production figures diff --git a/shared/game_state/combat.js b/shared/game_state/combat.js index a989941..7b32127 100644 --- a/shared/game_state/combat.js +++ b/shared/game_state/combat.js @@ -302,6 +302,7 @@ export const combatMethods = { _destroyUnit(unit) { const index = this.units.indexOf(unit); if (index >= 0) this.units.splice(index, 1); + this._unindexUnit(unit); this._unitById.delete(unit.id); }, diff --git a/shared/game_state/diplomacy.js b/shared/game_state/diplomacy.js index a74cae9..9646a58 100644 --- a/shared/game_state/diplomacy.js +++ b/shared/game_state/diplomacy.js @@ -37,6 +37,7 @@ export const diplomacyMethods = { const k = warKey(actor, target); if (this.wars.has(k)) return false; this.wars.add(k); + this._invalidateSpatialCaches(); this.conflicts.set(k, { a: Math.min(actor, target), b: Math.max(actor, target), diff --git a/shared/game_state/industry.js b/shared/game_state/industry.js index 505e384..b372f2a 100644 --- a/shared/game_state/industry.js +++ b/shared/game_state/industry.js @@ -208,22 +208,23 @@ export const industryMethods = { }; }, - // 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. + // How much of its capacity a converter should run at. Its output follows what + // it has actually been selling across the whole watch window, not its nameplate + // capacity: a building ramps up only as demand is sustained and eases off again + // when demand fades, 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; + // A converter that cannot cover its power bill at today's prices does not run + // at all; idling beats making a loss. + if (!this._converterProfitable(k, proto, agent)) return 0; 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; + expected = sales.reduce((sum, value) => sum + value, 0) / sales.length; } - const target = Math.max(capacity, expected * 2); + const target = expected * 2; const store = this.resourceStock ? this.resourceStock.get(k) : null; const stock = (store || {})[proto.resource] || 0; if (stock <= target) return 1; @@ -231,6 +232,24 @@ export const industryMethods = { return Math.max(0.1, Math.min(1, expected / capacity)); }, + // Whether a converter can cover its power bill at today's market prices. Making + // a material that sells for less than the energy it burns is a loss the private + // agent will not take, so it idles for the day instead. Efficiency research + // cuts the power each unit needs, so it only ever helps. + _converterProfitable(k, proto, agent) { + if (!proto || proto.resource === "energy") return true; + const level = agent ? agent.level : 0; + const output = resourceBuildingOutputAt(proto, level); + if (!(output > 0)) return true; + const owner = this.tileImprovementOwner.get(k); + const efficiency = 1 + this.resourceEfficiency(owner, proto.resource); + const energy = resourceBuildingInputAt(proto, level, "energyPerDay") / efficiency; + if (!(energy > 0)) return true; + const revenue = output * this.getResourcePrice(proto.resource); + const bill = energy * this.getResourcePrice("energy"); + return revenue >= bill; + }, + // 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 diff --git a/shared/game_state/movement.js b/shared/game_state/movement.js index 1f4c062..f6e23c8 100644 --- a/shared/game_state/movement.js +++ b/shared/game_state/movement.js @@ -218,7 +218,7 @@ export const movementMethods = { hoursLeft -= needed; unit.pathIndex += 1; unit.progressHours = 0; - unit.coords = next; + this._moveUnitTo(unit, next); this._visibilityDirty = true; this._onUnitArrived(unit); if (this._isAirUnit(unit)) { diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js index 8fd59ac..febab01 100644 --- a/shared/game_state/resources.js +++ b/shared/game_state/resources.js @@ -31,6 +31,7 @@ import { steelNeedPerDay, luxuryNeedPerDay, foodMonthlyTarget, + stockpileTarget, producerEnergyPerDay, foodSynthesisEnergy, synthesisedFoodFor, @@ -38,7 +39,7 @@ import { protoUpkeep, constructionResourceCost as sharedConstructionResourceCost, } from "../resources.js"; -import { deliveryEnergyForResource, traverse } from "../economy_graph.js"; +import { deliveryEnergyKwh, deliveryEnergyForResource, traverse } from "../economy_graph.js"; import { buildingBuildCost } from "../rules.js"; import { Random } from "../rng.js"; import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js"; @@ -1333,21 +1334,31 @@ export const resourceMethods = { // Whether a city can reach another continent's markets: its own continent // holds a working friendly port. No working port, no foreign resources. _cityMarketAccess(city) { + const cache = this._marketAccessCache; + const cacheKey = `city:${city.id}`; + if (cache && cache.has(cacheKey)) return cache.get(cacheKey); const continent = this._continentOf(city.coords); + let result = false; for (const port of this.cities) { if (port.civ !== city.civ) continue; if (this._continentOf(port.coords) !== continent) continue; - if (this._hasSeaSupply(port.coords, port.civ)) return true; + if (this._hasSeaSupply(port.coords, port.civ)) { result = true; break; } } - return false; + if (cache) cache.set(cacheKey, result); + return result; }, _civMarketAccess(civ) { + const cache = this._marketAccessCache; + const cacheKey = `civ:${civ}`; + if (cache && cache.has(cacheKey)) return cache.get(cacheKey); + let result = false; for (const city of this.cities) { if (city.civ !== civ) continue; - if (this._cityMarketAccess(city)) return true; + if (this._cityMarketAccess(city)) { result = true; break; } } - return false; + if (cache) cache.set(cacheKey, result); + return result; }, // The grid power a region's land harvest draws, after its food-efficiency @@ -1367,7 +1378,20 @@ export const resourceMethods = { this.resourceTrade = new Map(); this.resourceExternal = new Map(); this._resourceShortages = new Map(); + // The food that actually reached each region's store today, split by where + // it came from. The potential harvest is known before the grid throttles it, + // so every view reads this map rather than recomputing a crop that a power + // shortfall never grew. + this._foodProduced = new Map(); this._resetTaxLedger(); + // The transport network cannot move while the day's trades run, so the many + // sourcing passes below share one set of graph walks and port-access checks. + // The walks themselves outlive the day (see `_tradeGraph`); only the + // port-access memo is rebuilt for the run. + this._marketAccessCache = new Map(); + // The people do not move during the day's trading, so each city's population + // is read once for every surplus check instead of once per check. + this._nodePopulationCache = new Map(); const market = new Map(); for (const id of RESOURCE_IDS) market.set(id, { supply: 0, demand: 0, price: this.getResourcePrice(id) }); this.resourceMarketStats = market; @@ -1405,6 +1429,7 @@ export const resourceMethods = { coords: city.coords, component: components.get(k), amount: produced, + source: "harvest", }); const component = ledger.get(components.get(k)); if (component) { @@ -1418,7 +1443,10 @@ export const resourceMethods = { // 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. + // the reachable market cannot actually supply, it grows itself. A region + // whose store sits below its reserve keeps growing past the day's meal + // toward that reserve while the marginal energy stays under the food price, + // so a war that empties the pantry is refilled from the grid, not by famine. const foodPrice = this.getResourcePrice("food"); const energyPrice = this.getResourcePrice("energy"); const nodes = this._resourceNodes(); @@ -1426,7 +1454,13 @@ export const resourceMethods = { for (const city of this.cities) { const produced = regionFood.get(city.id) || 0; const need = foodNeedPerDay(this.getCityEconomy(city).population); - const shortfall = need - produced; + const store = this.getCityResourceStock(city).food || 0; + const reserveGap = Math.max(0, stockpileTarget(need) - store); + // What the region would like to grow: the meal it cannot cover from the + // land, plus what it must add to refill the reserve to a month. + const shortfall = Math.max(0, need + reserveGap - produced); + // What it must grow whatever the price: the ration its land cannot grow. + const mustGrow = Math.max(0, need - produced); if (!(shortfall > 0)) continue; const allowSea = this._cityMarketAccess(city) && !this._isEncircled(city.coords, city.civ); @@ -1440,7 +1474,7 @@ export const resourceMethods = { // the market wins; the energy it actually spends falls by the same share. const foodEfficiency = 1 + this.resourceEfficiency(city.civ, "food"); const extra = synthesisedFoodFor( - foodPrice, energyPrice / foodEfficiency, shortfall, buyable + foodPrice, energyPrice / foodEfficiency, shortfall, buyable, mustGrow ); if (!(extra > 0)) continue; const componentId = components.get(key(city.coords.x, city.coords.y)); @@ -1454,6 +1488,7 @@ export const resourceMethods = { coords: city.coords, component: componentId, amount: extra, + source: "synthesis", }); this._foodSynthesis.set(city.id, extra); } @@ -1470,23 +1505,27 @@ export const resourceMethods = { for (const component of ledger.values()) { if (component.civ < 0) continue; component.available = component.supply + component.imports; - // 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); + // The harvest is served first: it is a sliver of the grid next to the + // cities' own draw, but a shortfall is a famine while an under-served city + // only dims. The cities come next, then the converters: an idle factory is + // the least bad loss of the three. + const foodServed = Math.min(component.foodEnergy, component.available); + component.foodScale = component.foodEnergy > 0 ? foodServed / component.foodEnergy : 1; + const afterFood = Math.max(0, component.available - foodServed); + const cityServed = Math.min(component.cityEnergy, afterFood); 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; + const afterCity = afterFood - cityServed; component.industrialScale = component.converterEnergy > 0 - ? Math.max(0, Math.min(1, afterFood / component.converterEnergy)) + ? Math.max(0, Math.min(1, afterCity / component.converterEnergy)) : 1; market.get("energy").supply += component.supply; market.get("energy").demand += component.demand; } + // Kept for the view (and the economy simulator's grid panel): what the day's + // power went to and how much the grid throttled each kind of load. + this._energyLedger = ledger; // A grid that cannot power its industry names the works left idle. this._noteGridPowerShortages(ledger, components); @@ -1502,6 +1541,9 @@ export const resourceMethods = { this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced; market.get("food").supply += produced; grownFood.set(entry.cityId, (grownFood.get(entry.cityId) || 0) + produced); + const slot = this._foodProduced.get(entry.cityId) || { harvest: 0, synthesis: 0 }; + slot[entry.source] += produced; + this._foodProduced.set(entry.cityId, slot); } // Middlemen top up for neighbours whose own power-limited harvest fell short @@ -1526,6 +1568,11 @@ export const resourceMethods = { // stores -- home regions first, then foreign ones. this._consumeCityResources(components, market, nodes); + // The army eats too, and its rations are drawn from the same stores after + // the prices are set. Count the appetite now, so the market's food price + // weighs the soldiers as well as the civilians. + market.get("food").demand += this._militaryFoodNeed(); + // 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); @@ -1565,6 +1612,11 @@ export const resourceMethods = { this._applyResourceShortages(ledger); this._pruneResourceStock(); + this._marketAccessCache = null; + this._nodePopulationCache = null; + // The trade-graph cache is deliberately *not* dropped here: it is kept until + // a road, railway, storage work or territory change bumps the network stamp, + // or a wartime unit move clears it. See `_tradeGraph`. }, // Every land tile assigned to the connected component of owned land it sits @@ -1784,6 +1836,8 @@ export const resourceMethods = { // supply searches all route around these tiles, so moving a unit in forces // the next recalculation to find another way -- or none. _hostileOccupied(civ) { + const cache = this._hostileOccupiedCache; + if (cache && cache.has(civ)) return cache.get(civ); const blocked = new Set(); for (const unit of this.units) { if (unit.civ === civ) continue; @@ -1791,6 +1845,7 @@ export const resourceMethods = { if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue; blocked.add(key(unit.coords.x, unit.coords.y)); } + if (cache) cache.set(civ, blocked); return blocked; }, @@ -1805,8 +1860,9 @@ export const resourceMethods = { const blocked = this._hostileOccupied(city.civ); const visited = new Set([origin]); const queue = [{ coords: city.coords, energy: 0 }]; - while (queue.length > 0) { - const current = queue.shift(); + let head = 0; + while (head < queue.length) { + const current = queue[head++]; for (const neighbour of this._neighbours(current.coords)) { const nk = key(neighbour.x, neighbour.y); if (visited.has(nk) || blocked.has(nk)) continue; @@ -1902,6 +1958,16 @@ export const resourceMethods = { return (store.food || 0) / need; }; const order = this.cities.slice().sort((a, b) => urgency(a) - urgency(b)); + // High-tech has no per-person appetite: the nation's buildings, units and + // works wear it out and the draw falls on the regions. A region sizes its + // reserve to its population share of that national burn. + const techPerCapita = new Map(); + for (const city of this.cities) { + if (techPerCapita.has(city.civ)) continue; + const burn = Math.max(0, this.getCivUpkeepResources(city.civ).hightech || 0); + const people = Math.max(1e-9, this.getPlayerPopulation(city.civ)); + techPerCapita.set(city.civ, burn / people); + } for (const city of order) { const population = this.getCityEconomy(city).population; const needs = { @@ -1911,7 +1977,6 @@ export const resourceMethods = { }; const store = this.getCityResourceStock(city); const shortage = {}; - const foodTarget = foodMonthlyTarget(population); // An encircled city is cut off from neighbours and the foreign market: // it lives off its own reserves until the ring is broken. A portless city // can still trade over land on its own continent, but not across water. @@ -1925,24 +1990,42 @@ export const resourceMethods = { const own = Math.min(store[id] || 0, need); store[id] = (store[id] || 0) - own; const dailyShortfall = need - own; - // Food is stocked ahead: the city tops its store up to 130% of the - // coming month's needs, not just today's. Steel and luxury are bought - // as they are eaten. - const wanted = id === "food" - ? Math.max(dailyShortfall, foodTarget - store[id]) - : dailyShortfall; + // A pantry below its month is a standing bid: the region wants to + // refill the reserve, so even a day the meal is covered prices food up + // while the store is thin. Spread over the reserve horizon, so an empty + // store bids at most a second helping of the daily need. + if (id === "food") { + const reserveGap = Math.max(0, stockpileTarget(need) - store[id]); + market.get("food").demand += reserveGap / RESOURCE_RULES.stockpileDays; + } + // Every material is stocked ahead: the region tops its store up to a + // month of the coming needs, not just today's. + const wanted = Math.max(dailyShortfall, stockpileTarget(need) - store[id]); let bought = 0; if (wanted > 0 && !cutOff) { bought = this._procureFromNeighbours(city, id, wanted, store, nodes, { allowSea }); } - // What was bought against today's need is eaten today; only the food - // buffer left over stays in the store. + // What was bought against today's need is eaten today; the buffer left + // over stays in the store. const consumed = Math.min(bought, dailyShortfall); if (consumed > 0) store[id] -= consumed; if (dailyShortfall > bought + 0.0001 && need > 0) { shortage[id] = (dailyShortfall - bought) / need; + // The food a region could not buy is counted again as demand, so a + // famine bids the national price up rather than passing unseen. + if (id === "food") { + market.get("food").demand += (dailyShortfall - bought) * RESOURCE_RULES.famineBidFactor; + } } } + // High-tech is not eaten here -- the state draws it later for upkeep and + // research -- so the region only tops the store up to its month's reserve. + const techNeed = (techPerCapita.get(city.civ) || 0) * population; + market.get("hightech").demand += techNeed; + const techWanted = stockpileTarget(techNeed) - (store.hightech || 0); + if (techWanted > 0 && !cutOff) { + this._procureFromNeighbours(city, "hightech", techWanted, store, nodes, { allowSea }); + } if (Object.keys(shortage).length > 0) this._resourceShortages.set(city.id, shortage); } }, @@ -2187,48 +2270,81 @@ export const resourceMethods = { }, // The storage nodes within reach of a city that hold a surplus of `id`, in - // order of transport energy. A breadth-first search over land and sea, capped - // at RESOURCE_TRADE_RADIUS tiles. `routeEnergyPerTonne` is the accumulated - // kWh to move one tonne from the city to that node. + // order of transport energy. A breadth-first search over land and sea, with + // land capped at `RESOURCE_TRADE_RADIUS` tiles and sea lanes running any + // distance. `routeEnergyPerTonne` is the accumulated kWh to move one tonne + // from the city to that node. _nearbySuppliers(city, id, nodes, allowSea = true) { return this._tradeReachable(city, nodes, allowSea, (node) => this._nodeSurplus(node, id) > 0, id); }, // The storage nodes a city can haul from over the transport network: a - // breadth-first spread over land and sea, capped at RESOURCE_TRADE_RADIUS - // tiles, that hands every reachable node to `accept`. `resource` only names - // the commodity charged for the haul; the transport rules themselves are - // commodity-independent, so the delivery graph passes one to price its rides. - // The traversal itself lives in the economy-graph toolkit; this method only - // describes the network it walks. + // breadth-first spread over land and sea that hands every reachable node to + // `accept`. Land legs are capped at `RESOURCE_TRADE_RADIUS` tiles, but a + // shipping lane between ports carries goods any distance, so only land counts + // against the cap. `resource` only names the commodity charged for the haul; + // the transport rules themselves are commodity-independent, so the delivery + // graph passes one to price its rides. The traversal itself lives in the + // economy-graph toolkit; this method only describes the network it walks. _tradeReachable(city, nodes, allowSea, accept, resource = "steel") { + const list = this._tradeGraph(city, nodes, allowSea); + const mass = resourceById(resource)?.tonnesPerUnit || 1; + const scaled = mass === 1 + ? list + : list.map((node) => ({ ...node, routeEnergyPerTonne: node.routeEnergyPerTonne * mass })); + return accept ? scaled.filter((node) => accept(node)) : scaled; + }, + + // The full set of storage nodes a city can haul from over the transport + // network, with the least per-tonne energy to each. Independent of the day's + // changing surpluses, so it is computed once per (city, mode) and reused by + // every sourcing pass -- and across days -- until the network changes. The + // graph is also commodity-independent -- every commodity rides the same roads, + // and only its mass per unit scales the energy -- so the caller folds the + // commodity's mass in afterwards. The stamp below mixes the counters that move + // when a road, railway, work or territory is added or removed or redrawn with + // the sizes of the collections themselves, so even a direct edit picks a fresh + // stamp and rebuilds lazily; it is all O(1) reads. + _tradeGraph(city, nodes, allowSea) { + const stamp = `${this._improvementVersion}:${this.roads.size}:${this.railways.size}` + + `:${this._tileImprovementVersion}:${this.tileImprovements.size}:${this._territoryVersion}`; + if (!this._tradeGraphCache || this._tradeGraphStamp !== stamp) { + this._tradeGraphCache = new Map(); + this._tradeGraphStamp = stamp; + } + const cache = this._tradeGraphCache; + const cacheKey = `${city.id}|${allowSea ? 1 : 0}`; + const hit = cache.get(cacheKey); + if (hit) return hit; const byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node])); const origin = key(city.coords.x, city.coords.y); const blocked = this._hostileOccupied(city.civ); const reached = traverse(city.coords, { id: (coords) => key(coords.x, coords.y), - maxDistance: RESOURCE_TRADE_RADIUS, - neighbours: (coords) => this._tradeNeighbours(city, coords, allowSea, resource, blocked), + maxWeight: RESOURCE_TRADE_RADIUS, + neighbours: (coords) => this._tradeNeighbours(city, coords, allowSea, blocked), accept: (coords) => { const here = byKey.get(key(coords.x, coords.y)); if (!here || key(here.coords.x, here.coords.y) === origin) return false; const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ); - return !hostile && accept(here); + return !hostile; }, }); - return reached.map((entry) => ({ + const list = reached.map((entry) => ({ ...byKey.get(key(entry.node.x, entry.node.y)), tiles: entry.distance, routeEnergyPerTonne: entry.cost, })); + cache.set(cacheKey, list); + return list; }, // The passable outward edges of one tile for the trade network: land legs // must run on a road or railway, sea legs cross open water and need a working // port, and goods never cross the land of a nation you are at war with. Each - // edge is priced in the transport energy one tonne of `resource` burns over - // it, so the graph is commodity-aware while the traversal is not. - _tradeNeighbours(city, coords, allowSea, resource, blocked) { + // edge is priced in the transport energy one tonne burns over it; the caller + // scales by the commodity's mass, so every commodity shares one graph. + _tradeNeighbours(city, coords, allowSea, blocked) { const edges = []; for (const neighbour of this._neighbours(coords)) { const nk = key(neighbour.x, neighbour.y); @@ -2254,7 +2370,10 @@ export const resourceMethods = { } edges.push({ node: neighbour, - cost: deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1), + cost: deliveryEnergyKwh(1, 1, mode, tile.movementCostMultiplier || 1), + // Only land legs are counted against the trade radius: a shipping lane + // runs as far as the coast does. + weight: sea ? 0 : 1, }); } return edges; @@ -2302,7 +2421,7 @@ export const resourceMethods = { _nodeSurplus(node, id) { if (id === "hightech") return Math.max(0, node.stock.hightech || 0); - const population = node.city ? this.getCityEconomy(node.city).population : 0; + const population = node.city ? this._nodeCityPopulation(node.city) : 0; const perDay = id === "food" ? foodNeedPerDay(population) : id === "steel" @@ -2313,6 +2432,19 @@ export const resourceMethods = { return Math.max(0, (node.stock[id] || 0) - perDay * SUPPLIER_RESERVE_DAYS); }, + // A city's population for the day's surplus checks. The region walk behind + // `getCityEconomy` is expensive and a sourcing pass asks about the same city + // many times; the people do not move until the famine deaths at the end of + // the tick, so one figure per city is read for the whole run. The memo only + // exists during `_tickResources`; every other caller recomputes it. + _nodeCityPopulation(city) { + const cache = this._nodePopulationCache; + if (cache && cache.has(city.id)) return cache.get(city.id); + const population = this.getCityEconomy(city).population; + if (cache) cache.set(city.id, population); + return population; + }, + // A shortage of food, steel or luxury costs popularity; a grid starved of // power costs popularity too. Each city is charged once per day. _applyResourceShortages(ledger) { @@ -2487,8 +2619,10 @@ export const resourceMethods = { const blocked = this._hostileOccupied(civ); const visited = new Set([key(unit.coords.x, unit.coords.y)]); const queue = [unit.coords]; - while (queue.length > 0) { - const coords = queue.shift(); + let head = 0; + let marketAccess = null; + while (head < queue.length) { + const coords = queue[head++]; for (const neighbour of this._neighbours(coords)) { const nk = key(neighbour.x, neighbour.y); if (visited.has(nk) || blocked.has(nk)) continue; @@ -2497,7 +2631,8 @@ export const resourceMethods = { const sea = tile.terrainClass === "Sea"; if (sea) { // A shipping lane needs a working port at the nation's end. - if (!this._civMarketAccess(civ)) continue; + if (marketAccess === null) marketAccess = this._civMarketAccess(civ); + if (!marketAccess) continue; } else if (!(this.railways.has(nk) || this.roads.has(nk))) { continue; } @@ -2553,6 +2688,30 @@ export const resourceMethods = { this._recordResourceSpend(civ, "upkeep", spent, "food"); }, + // The day's ration multiplier for one land unit: a unit resting at home heals + // and eats more, and a unit in combat eats more too. + _unitFoodMultiplier(unit, battle) { + const here = this.cityAt(unit.coords); + const resting = !!(here && here.civ === unit.civ && unit.hp < unit.maxHp); + if (resting) return RESOURCE_RULES.infantryRestingFoodMultiplier; + return battle.has(unit.id) ? RESOURCE_RULES.infantryCombatFoodMultiplier : 1; + }, + + // The food every land unit will eat today. Unit supply runs after the day's + // prices are set, so the market needs this forecast to count the army's + // appetite alongside the civilians' -- otherwise the price only ever sees the + // civilian half of the demand. + _militaryFoodNeed() { + const battle = this._battleUnitIds(); + let total = 0; + for (const unit of this.units) { + const proto = this.unitProto(unit); + if (!proto || !this._isLandUnitProto(proto)) continue; + total += unitFoodPerDay(proto) * this._unitFoodMultiplier(unit, battle); + } + return total; + }, + // Every land unit eats a day from its carried stores. At home, or in foreign // territory with a clear route to an allied city, it is fed and topped back // up to a full pack; cut off, the days run down and then the soldiers start @@ -2565,13 +2724,7 @@ export const resourceMethods = { const proto = this.unitProto(unit); if (!proto || !this._isLandUnitProto(proto)) continue; const ration = unitFoodPerDay(proto); - const here = this.cityAt(unit.coords); - const resting = !!(here && here.civ === unit.civ && unit.hp < unit.maxHp); - const multiplier = resting - ? RESOURCE_RULES.infantryRestingFoodMultiplier - : battle.has(unit.id) - ? RESOURCE_RULES.infantryCombatFoodMultiplier - : 1; + const multiplier = this._unitFoodMultiplier(unit, battle); const city = this._unitSupplyCity(unit); const fed = city ? this._provisionUnit(unit, city, ration * multiplier) : false; if (fed) { diff --git a/shared/game_state/warfare.js b/shared/game_state/warfare.js index b4e942d..e89f0c9 100644 --- a/shared/game_state/warfare.js +++ b/shared/game_state/warfare.js @@ -325,12 +325,42 @@ export const warfareMethods = { this._emitChanged(); }, - // Every unit of another nation within `radius` tiles of `coords`. + // Every unit within `radius` tiles of `coords`. Walks outward over the tile + // index rather than scanning the whole army, so a map-wide battery does not + // pay for every unit twice a day. _unitsWithin(coords, radius) { const result = []; - const origin = { x: coords.x, y: coords.y }; - for (const unit of this.units) { - if (this.topology.tileDistance(origin, unit.coords) <= radius) result.push(unit); + if (!(radius >= 0)) return result; + if (!this._unitsByTile) { + for (const unit of this.units) { + if (this.topology.tileDistance(coords, unit.coords) <= radius) result.push(unit); + } + return result; + } + const seen = new Set([key(coords.x, coords.y)]); + let frontier = [coords]; + let depth = 0; + while (frontier.length > 0 && depth <= radius) { + for (const c of frontier) { + const bucket = this._unitsByTile.get(key(c.x, c.y)); + if (bucket) { + for (const unit of bucket) { + if (this.topology.tileDistance(coords, unit.coords) <= radius) result.push(unit); + } + } + } + if (depth === radius) break; + const next = []; + for (const c of frontier) { + for (const neighbour of this._neighbours(c)) { + const nk = key(neighbour.x, neighbour.y); + if (seen.has(nk)) continue; + seen.add(nk); + next.push(neighbour); + } + } + frontier = next; + depth += 1; } return result; }, diff --git a/shared/game_state/world.js b/shared/game_state/world.js index 283f337..a83a82d 100644 --- a/shared/game_state/world.js +++ b/shared/game_state/world.js @@ -258,6 +258,7 @@ export const worldMethods = { } this.units.push(unit); this._unitById.set(this._nextUnitId, unit); + this._indexUnit(unit); this._nextUnitId += 1; return unit; }, diff --git a/shared/resources.js b/shared/resources.js index 72b46d8..63284e6 100644 --- a/shared/resources.js +++ b/shared/resources.js @@ -83,12 +83,17 @@ export function tileFoodOutput(tile) { return RESOURCE_RULES.foodPerTilePerDay * factor; } +// The reserve a region wants to hold of a material it consumes: a month at the +// current appetite. Every storable good is buffered to the same horizon, so the +// same figure sizes food, steel, luxury and high-tech reserves alike. +export function stockpileTarget(needPerDay) { + return Math.max(0, needPerDay) * RESOURCE_RULES.stockpileDays; +} + // How much food a city wants in store before the coming month: a month at the -// current appetite, with a 130% cushion. +// current appetite. export function foodMonthlyTarget(population) { - return foodNeedPerDay(population) * - RESOURCE_RULES.daysPerMonth * - RESOURCE_RULES.foodMonthlyBuffer; + return stockpileTarget(foodNeedPerDay(population)); } // The energy a region spends to force `extraFood` tonnes a day from the land @@ -105,18 +110,25 @@ 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) { +// How much extra food a region grows for a `shortfall` it can 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. +// +// `shortfall` is how much the region would *like* to grow -- the day's meal +// plus whatever it must add to refill an empty reserve. `mustGrow` is the part +// it cannot avoid: the ration it must force because it cannot buy that much. It +// defaults to the whole shortfall, which is the old single-target behaviour. +export function synthesisedFoodFor( + foodPrice, energyPrice, shortfall, buyable = Infinity, mustGrow = shortfall +) { const gap = Math.max(0, shortfall); if (!(gap > 0)) return 0; - const forced = Math.max(0, gap - Math.max(0, buyable)); + const forced = Math.max(0, Math.max(0, mustGrow) - Math.max(0, buyable)); const quadratic = RESOURCE_RULES.foodSynthesisQuadratic; if (!(energyPrice > 0) || !(quadratic > 0) || !(foodPrice > 0)) { return Math.min(gap, forced); diff --git a/tests/economy_graph_test.js b/tests/economy_graph_test.js new file mode 100644 index 0000000..053785c --- /dev/null +++ b/tests/economy_graph_test.js @@ -0,0 +1,44 @@ +import { TestCase } from "./framework/test_case.js"; +import { traverse } from "../shared/economy_graph.js"; + +// A small undirected graph: `edges` maps a node to `[neighbour, weight]` pairs. +function graph(edges) { + return (node) => (edges[node] || []).map(([next, weight]) => ({ node: next, cost: 1, weight })); +} + +export class EconomyGraphTest extends TestCase { + test_sea_legs_do_not_count_against_the_cap() { + // 0->1->2 are sea legs (weight 0); 2->3 and 3->4 are land (weight 1). + const neighbours = graph({ + 0: [[1, 0]], + 1: [[0, 0], [2, 0]], + 2: [[1, 0], [3, 1]], + 3: [[2, 1], [4, 1]], + 4: [[3, 1]], + }); + const reached = traverse(0, { neighbours, maxWeight: 1 }); + const nodes = reached.map((entry) => entry.node); + this.assertTrue(nodes.includes(3), "a land leg within the cap is reached past any sea"); + this.assertFalse(nodes.includes(4), "a second land leg exceeds the cap"); + } + + test_land_legs_still_count_against_the_cap() { + const neighbours = graph({ + 0: [[1, 1]], + 1: [[0, 1], [2, 1]], + 2: [[1, 1]], + }); + const reached = traverse(0, { neighbours, maxWeight: 1 }); + const nodes = reached.map((entry) => entry.node); + this.assertTrue(nodes.includes(1), "the first land leg is reached"); + this.assertFalse(nodes.includes(2), "the second land leg exceeds the cap"); + } + + test_an_edge_without_a_weight_counts_as_one() { + const neighbours = graph({ 0: [[1, undefined]], 1: [[0, undefined], [2, undefined]], 2: [[1, undefined]] }); + const reached = traverse(0, { neighbours, maxWeight: 1 }); + const nodes = reached.map((entry) => entry.node); + this.assertTrue(nodes.includes(1)); + this.assertFalse(nodes.includes(2), "the default weight is one step"); + } +} diff --git a/tests/food_power_test.js b/tests/food_power_test.js new file mode 100644 index 0000000..6cea2bf --- /dev/null +++ b/tests/food_power_test.js @@ -0,0 +1,88 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState, cityOf, grantBuilding } from "./framework/helpers.js"; +import { RESOURCE_IDS, RESOURCE_RULES } from "../shared/data.js"; +import { key } from "../shared/hex.js"; +import { foodNeedPerDay, unitFoodPerDay, stockpileTarget } from "../shared/resources.js"; + +function marketOf(state) { + return new Map(RESOURCE_IDS.map((id) => [ + id, { supply: 0, demand: 0, price: state.getResourcePrice(id) }, + ])); +} + +// The grid component that owns `city`. +function componentOf(state, city) { + for (const component of state._energyLedger.values()) { + if ((component.cities || []).some((entry) => entry.city === city)) return component; + } + return null; +} + +export class FoodPowerTest extends TestCase { + test_the_store_gets_the_power_scaled_crop_not_the_potential_one() { + const state = smallState(); + state._tickResources(); + let delivered = 0; + for (const grown of state._foodProduced.values()) { + delivered += grown.harvest + grown.synthesis; + } + const market = state.resourceMarketStats.get("food"); + this.assertGreater(delivered, 0, "the fixture grows food"); + this.assertApprox(market.supply, delivered, 1e-6, "supply is what reached the store"); + } + + test_the_harvest_is_served_before_the_city_load() { + const state = smallState(); + const city = cityOf(state, 0); + // A city block enormous enough to need more power than the grid makes. + grantBuilding(state, city, "shelters", 20); + state._tickResources(); + const component = componentOf(state, city); + this.assertTrue(component, "the city sits in a grid component"); + this.assertGreater(component.cityEnergy, component.supply, "the grid cannot serve the city"); + this.assertApprox(component.foodScale, 1, 1e-9, "the harvest is served first, in full"); + this.assertGreater(component.cityShortage, 0, "the cities are the ones that brown out"); + } + + test_army_rations_are_counted_in_the_food_demand() { + const state = smallState(["france"], 12345, { citiesPerCiv: 1 }); + const city = cityOf(state, 0); + const infantry = state.protoUnits.find((proto) => proto.id === "modern_infantry"); + this.assertTrue(infantry, "the fixture can field infantry"); + let civilianNeed = 0; + for (const c of state.cities) civilianNeed += foodNeedPerDay(state.getCityEconomy(c).population); + // One soldier fields a company that eats like a thousand civilians. + state._spawnUnit(city.coords, 0, infantry, city.id); + state._tickResources(); + const demand = state.resourceMarketStats.get("food").demand; + this.assertGreater( + demand, civilianNeed + unitFoodPerDay(infantry) - 1e-6, + "the market's food demand includes the army's ration" + ); + } + + test_a_region_that_cannot_buy_food_bids_the_price_up() { + const state = smallState(["france"], 12345, { citiesPerCiv: 1 }); + const city = cityOf(state, 0); + // More mouths than the land and every reachable store can feed. + for (const coords of state.regionTiles(city)) { + const k = key(coords.x, coords.y); + state.tilePopulation.set(k, (state.tilePopulation.get(k) || 0) * 1000); + } + state._gdpPerCapitaCache.clear(); + state.getCityResourceStock(city).food = 0; + const need = foodNeedPerDay(state.getCityEconomy(city).population); + const market = marketOf(state); + state._consumeCityResources(new Map(), market); + // Three bids stack: the day's ration, the standing wish to refill the empty + // pantry (a month's reserve spread over the reserve horizon), and the unmet + // ration counted again, so a famine bids the price up. + const reserveBid = stockpileTarget(need) / RESOURCE_RULES.stockpileDays; + this.assertApprox( + market.get("food").demand, + need + reserveBid + need * RESOURCE_RULES.famineBidFactor, + 1e-3, + "the empty pantry, the reserve and the unmet ration all bid the price up" + ); + } +} diff --git a/tests/food_synthesis_test.js b/tests/food_synthesis_test.js index c14d641..ba57903 100644 --- a/tests/food_synthesis_test.js +++ b/tests/food_synthesis_test.js @@ -7,6 +7,7 @@ import { tileFoodOutput, foodSynthesisEnergy, foodSynthesisMarginalEnergy, + stockpileTarget, synthesisedFoodFor, } from "../shared/resources.js"; @@ -92,9 +93,12 @@ export class FoodSynthesisModelTest extends TestCase { if (coords.x === city.coords.x && coords.y === city.coords.y) continue; state.tilePopulation.set(key(coords.x, coords.y), 0); } - const shortfall = foodNeedPerDay(state.getCityEconomy(city).population) - - regionFood(state, city); + const need = foodNeedPerDay(state.getCityEconomy(city).population); + const shortfall = need - regionFood(state, city); this.assertGreater(shortfall, 0, "the fixture region opens short of food"); + // Hold the reserve full so the test isolates the day's ration: with the + // pantry a month deep, only the meal the land cannot grow is forced. + state.getCityResourceStock(city).food = stockpileTarget(need); // Cheap energy against a dear market: force as much as the shortfall allows. state.setResourcePrice("energy", 0.00001); state.setResourcePrice("food", 1e9); @@ -103,6 +107,25 @@ export class FoodSynthesisModelTest extends TestCase { this.assertApprox(extra, shortfall, 1, "the whole shortfall is grown"); } + test_a_region_grows_extra_to_refill_an_empty_reserve() { + const state = smallState(); + const city = cityOf(state, 0); + // A self-sufficient region with an empty pantry still wants its month back. + this.assertGreater(regionFood(state, city), 0, "the region has farmland"); + state.getCityResourceStock(city).food = 0; + // The reserve is dearer to force than the land, but cheap energy against a + // dear market makes the extra worth growing. + state.setResourcePrice("energy", 0.00001); + state.setResourcePrice("food", 1e9); + state._tickResources(); + const extra = state._foodSynthesis.get(city.id) || 0; + this.assertGreater(extra, 0, "an empty reserve drives synthesis above the harvest"); + this.assertGreater( + state.getCityResourceStock(city).food, regionFood(state, city), + "the store rises toward the reserve instead of hugging the day's harvest" + ); + } + test_a_region_buys_instead_when_energy_is_dearer_than_food() { const state = smallState(); const city = cityOf(state, 0); diff --git a/tests/industry_test.js b/tests/industry_test.js index 1fb2dd5..f53a872 100644 --- a/tests/industry_test.js +++ b/tests/industry_test.js @@ -255,4 +255,35 @@ export class PowerMarketTest extends TestCase { "selling out is a reason to grow" ); } + + test_a_converter_idles_when_its_output_sells_below_the_power_it_burns() { + const state = smallState(); + const { k, agent } = placeProducer(state, "steel_mill"); + const proto = tileImprovementById("steel_mill"); + state.setResourcePrice("steel", 1); + this.assertEqual( + state._converterProductionScale(k, proto, agent), 0, + "a loss-making mill switches off" + ); + state.setResourcePrice("steel", 1e12); + this.assertGreater( + state._converterProductionScale(k, proto, agent), 0, + "and runs again once its steel is worth more than its power" + ); + } + + test_output_follows_sustained_sales_not_nameplate_capacity() { + const state = smallState(); + const { k, agent } = placeProducer(state, "steel_mill"); + const proto = tileImprovementById("steel_mill"); + const capacity = resourceBuildingOutputAt(proto, 0); + // Selling a third of what it can make, with a little over two days of that + // demand in store: the mill makes what it sells, not its nameplate output. + agent.sales = new Array(6).fill(capacity * (1 / 3)); + state._ensureResourceStock(k).steel = capacity * 0.8; + this.assertApprox( + state._converterProductionScale(k, proto, agent), 1 / 3, 1e-6, + "output tracks sustained demand" + ); + } } diff --git a/tests/money_test.js b/tests/money_test.js index 5b7658b..8e78011 100644 --- a/tests/money_test.js +++ b/tests/money_test.js @@ -43,10 +43,10 @@ export class MoneyTest extends TestCase { const buyer = cityOf(state, 0); const seller = cityOf(state, 1); const sellerCode = state.currencyOf(1).code; - // Empty every home store so only the foreign region can supply. - for (const city of state.cities) { - if (city.civ === 0) state.getCityResourceStock(city).food = 0; - } + // Empty every store so only the intended foreign region can supply: with a + // sea lane running any distance, a nearer home or foreign one would be + // bought from first. + for (const city of state.cities) state.getCityResourceStock(city).food = 0; state.getCityResourceStock(seller).food = 1.0e9; const before = state.getRegionCash(seller).get(sellerCode) || 0; const payer = state._treasuryPayer(0); @@ -216,8 +216,10 @@ export class MoneyTest extends TestCase { const state = smallState(); const buyer = cityOf(state, 0); const seller = cityOf(state, 1); + // Only the intended foreign seller holds food, so a nearer home or foreign + // store is not bought from first now that a sea lane spans the coast. + for (const city of state.cities) state.getCityResourceStock(city).food = 0; state.getCityResourceStock(seller).food = 1e9; - state.getCityResourceStock(buyer).food = 0; const buyerCode = state.currencyOf(0).code; const sellerCode = state.currencyOf(1).code; const buyerBefore = state.getRegionCash(buyer).get(buyerCode) || 0; diff --git a/tests/resources_test.js b/tests/resources_test.js index d69a901..6bf6a2d 100644 --- a/tests/resources_test.js +++ b/tests/resources_test.js @@ -61,8 +61,8 @@ export class ResourceRulesTest extends TestCase { this.assertApprox(luxuryNeedPerDay(2_000_000), 20_000); } - test_food_target_is_a_month_with_a_cushion() { - const need = foodNeedPerDay(2_000_000) * 30 * RESOURCE_RULES.foodMonthlyBuffer; + test_food_target_is_a_flat_month() { + const need = foodNeedPerDay(2_000_000) * RESOURCE_RULES.stockpileDays; this.assertApprox(foodMonthlyTarget(2_000_000), need); } @@ -332,11 +332,9 @@ export class ResourceModelTest extends TestCase { state.budgets.set(0, 1e15); const city = cityOf(state, 0); const seller = cityOf(state, 1); - // Every city of the nation must be empty, or the draw would find their - // stores before it reaches a foreign region. - for (const own of state.cities) { - if (own.civ === 0) state.getCityResourceStock(own).steel = 0; - } + // Empty every other store, or a nearer home or foreign seller would be + // reached first now that a port connects the whole coast. + for (const own of state.cities) state.getCityResourceStock(own).steel = 0; state.getCityResourceStock(seller).steel = 1.0e9; const before = state.getBudget(0); const ok = state._payConstructionResources(0, city.coords, { steel: 1000, hightech: 0 }); @@ -516,8 +514,10 @@ export class ResourceModelTest extends TestCase { state.budgets.set(0, 1e15); const buyer = cityOf(state, 0); const seller = cityOf(state, 1); + // Only the intended foreign seller holds food, so a home region never + // satisfies the buy now that the trade net spans the coast. + for (const own of state.cities) state.getCityResourceStock(own).food = 0; state.getCityResourceStock(seller).food = 1e9; - state.getCityResourceStock(buyer).food = 0; const bought = state._procureFromNeighbours( buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes() ); diff --git a/tests/server_test.js b/tests/server_test.js index 136cd54..aef2357 100644 --- a/tests/server_test.js +++ b/tests/server_test.js @@ -76,7 +76,9 @@ export class ServerStaticTest extends TestCase { async test_cli_starts_and_reports_its_url() { const script = fileURLToPath(new URL("../server/server.js", import.meta.url)); - const child = spawn(process.execPath, [script, "--port", "0"], { stdio: ["ignore", "pipe", "pipe"] }); + // `--warmup 0`: this checks the CLI binds and reports its URL, not the + // settle; skipping it keeps the test quick. + const child = spawn(process.execPath, [script, "--port", "0", "--warmup", "0"], { stdio: ["ignore", "pipe", "pipe"] }); try { const line = await new Promise((resolve, reject) => { let out = ""; diff --git a/tests/stockpile_test.js b/tests/stockpile_test.js new file mode 100644 index 0000000..df6f266 --- /dev/null +++ b/tests/stockpile_test.js @@ -0,0 +1,66 @@ +import { TestCase } from "./framework/test_case.js"; +import { smallState, cityOf, grantBuilding } from "./framework/helpers.js"; +import { RESOURCE_IDS, RESOURCE_RULES } from "../shared/data.js"; +import { key } from "../shared/hex.js"; +import { steelNeedPerDay, luxuryNeedPerDay, stockpileTarget } from "../shared/resources.js"; + +// Puts a reachable producer for `resource` on an adjacent owned land tile, with +// a bottomless stock, so a city can always top its reserve up. `index` picks a +// different neighbour for each producer. +function placeProducer(state, city, improvementId, resource, amount, index = 0) { + const owned = state.topology.neighbours(city.coords.x, city.coords.y) + .filter((n) => state._isLand(n) && state.civAt(n) === city.civ && !state.cityAt(n)); + const neighbour = owned[index]; + const k = key(neighbour.x, neighbour.y); + if (!state.roads.has(k) && !state.railways.has(k)) state.roads.add(k); + state.tileImprovements.set(k, improvementId); + state.tileImprovementOwner.set(k, city.civ); + state.tileImprovementHp.set(k, 1000); + state._ensureResourceStock(k)[resource] = amount; + return k; +} + +function marketOf(state) { + return new Map(RESOURCE_IDS.map((id) => [ + id, { supply: 0, demand: 0, price: state.getResourcePrice(id) }, + ])); +} + +export class StockpileTest extends TestCase { + test_a_month_of_every_material_is_one_flat_horizon() { + this.assertApprox(stockpileTarget(100), 100 * RESOURCE_RULES.stockpileDays); + this.assertApprox(stockpileTarget(0), 0); + } + + test_regions_stockpile_a_month_of_steel_and_luxury() { + const state = smallState(["france"], 12345, { citiesPerCiv: 1 }); + const city = cityOf(state, 0); + const population = state.getCityEconomy(city).population; + const store = state.getCityResourceStock(city); + // Below the target but enough to cover today: one day should top it up. + store.steel = steelNeedPerDay(population) * 5; + store.luxury = luxuryNeedPerDay(population) * 5; + placeProducer(state, city, "steel_mill", "steel", 1e9, 0); + placeProducer(state, city, "gold_mine", "luxury", 1e9, 1); + state._consumeCityResources(new Map(), marketOf(state)); + this.assertApprox(store.steel, stockpileTarget(steelNeedPerDay(population)), 1e-3); + this.assertApprox(store.luxury, stockpileTarget(luxuryNeedPerDay(population)), 1e-3); + } + + test_a_region_reserves_a_month_of_high_tech_against_its_share_of_the_burn() { + const state = smallState(["france"], 12345, { citiesPerCiv: 1 }); + const city = cityOf(state, 0); + grantBuilding(state, city, "research_lab"); + placeProducer(state, city, "chip_foundry", "hightech", 1e9); + const population = state.getCityEconomy(city).population; + const burn = state.getCivUpkeepResources(0).hightech; + this.assertGreater(burn, 0, "the lab and foundry wear high-tech"); + const need = (burn / state.getPlayerPopulation(0)) * population; + const store = state.getCityResourceStock(city); + store.hightech = 0; + const market = marketOf(state); + state._consumeCityResources(new Map(), market); + this.assertApprox(store.hightech, stockpileTarget(need), 1e-3); + this.assertApprox(market.get("hightech").demand, need, 1e-3, "the appetite is priced in"); + } +} diff --git a/tests/trade_graph_test.js b/tests/trade_graph_test.js index b612bd1..1aa78f2 100644 --- a/tests/trade_graph_test.js +++ b/tests/trade_graph_test.js @@ -102,4 +102,40 @@ export class TradeGraphTest extends TestCase { this.assertEmpty(graph.links); this.assertEmpty(graph.edges); } + + // The walk is expensive (sea lanes flood the ocean), so it is kept across + // days until the network changes. Identity proves whether it was reused. + test_the_trade_walk_is_reused_until_the_network_changes() { + const state = smallState(); + const city = cityOf(state, 0); + const nodes = state._resourceNodes(); + const allowSea = state._cityMarketAccess(city); + const first = state._tradeGraph(city, nodes, allowSea); + const again = state._tradeGraph(city, nodes, allowSea); + this.assert(first === again, "an unchanged network reuses the cached walk"); + + state._improvementVersion += 1; + const rebuilt = state._tradeGraph(city, nodes, allowSea); + this.assert(first !== rebuilt, "a road change throws the cached walk away"); + } + + // A hostile camp can sever a route, so wartime movement drops the walk; in + // peacetime unit positions never enter it, so a move must not. + test_a_peacetime_unit_move_keeps_the_trade_walk() { + const state = smallState(); + const city = cityOf(state, 0); + const nodes = state._resourceNodes(); + const allowSea = state._cityMarketAccess(city); + const first = state._tradeGraph(city, nodes, allowSea); + + state._invalidateUnitCaches(); + const afterMove = state._tradeGraph(city, nodes, allowSea); + this.assert(first === afterMove, "with no war a unit move keeps the walk"); + + this.assertTrue(state.requestDeclareWar(0, 1), "the fixture declares a war"); + const atWar = state._tradeGraph(city, nodes, allowSea); + state._invalidateUnitCaches(); + const atWarAfterMove = state._tradeGraph(city, nodes, allowSea); + this.assert(atWar !== atWarAfterMove, "in war a unit move drops the walk"); + } }