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.
This commit is contained in:
2026-09-23 15:59:28 +02:00
parent 3418e80160
commit 4600b16399
25 changed files with 793 additions and 128 deletions
+24 -1
View File
@@ -19,7 +19,10 @@ everything else (transport included) is hand-written.
- Start the game server and client locally: - Start the game server and client locally:
`node server/server.js --port 27015 --bind 127.0.0.1`. Then open `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 `http://127.0.0.1:27015/` (redirects to `/client/index.html`). The browser
connects to `ws(s)://<page-origin>/ws`. connects to `ws(s)://<page-origin>/ws`. The server settles the world for 150
days before it listens so the economy opens established; pass `--warmup <days>`
(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): - Install the versioned git hooks (runs the tests on every commit):
`./scripts/install-git-hooks.sh`; uninstall with `./scripts/install-git-hooks.sh`; uninstall with
`git config --unset core.hooksPath`. `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` a known seed. The snapshot ships `mapConfig` and `MapView.ensureTerrain`
rebuilds the exact same terrain from it (not the global `MAP_CONFIG`), which rebuilds the exact same terrain from it (not the global `MAP_CONFIG`), which
also lets the DOM tests use a small map fixture. 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 - `shared/` holds the reusable algorithms so they stay testable without a
server: `hex.js` (topology/geometry), `map_generator.js`, `hex_pathfinder.js`, server: `hex.js` (topology/geometry), `map_generator.js`, `hex_pathfinder.js`,
`terrain_stats.js`, `rules.js`, `economy` figures in `game_state.js`, `terrain_stats.js`, `rules.js`, `economy` figures in `game_state.js`,
+13
View File
@@ -129,6 +129,9 @@ simplification worth revisiting. Anything unticked is still open.
### Delivering resources ### Delivering resources
* [x] Delivering 1 tonne of resources over 1 tile requires 10 MWh by truck over roads, 50 MWh * tile movement cost without roads, 1 MWh by train, 50 kWh by ship. * [x] 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] A delivery graph, revealed only in the economic map mode, is created.
* [x] The delivery graph is built * [x] The delivery graph is built
* [~] A city always buys from the cheapest source: a nearby store whose * [~] 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 ## 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 - [ ] A dedicated structure hit-point model for cities and tile improvements
(see the `[~]` note above). (see the `[~]` note above).
- [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`, - [ ] Real art for the placeholder "dummy icon" SVGs: `icon_radar.svg`,
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit. // 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. // The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [ 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", "hash": "15d0c38",
"date": "2026-09-23", "date": "2026-09-23",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-22", "date": "2026-09-22",
"subject": "Fixed a typo for Northumbrian towns", "subject": "Fixed a typo for Northumbrian towns",
"body": "" "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."
} }
]; ];
+5
View File
@@ -107,6 +107,11 @@ export class GameServer extends EventEmitter {
this.state.onChanged(() => { this.state.onChanged(() => {
this._stateDirty = true; 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; this._configured = true;
for (const peerId of this.network.getPeerIds()) { for (const peerId of this.network.getPeerIds()) {
this._assignCiv(peerId, this.peerNames.get(peerId) || ""); this._assignCiv(peerId, this.peerNames.get(peerId) || "");
+7 -1
View File
@@ -111,6 +111,7 @@ export function startServer({
testing = false, testing = false,
civilisations = [], civilisations = [],
mapConfig = null, mapConfig = null,
warmupDays = 0,
} = {}) { } = {}) {
const httpServer = createServer(serveStatic); const httpServer = createServer(serveStatic);
const wsServer = new WebSocketServer(httpServer, { path: "/ws" }); const wsServer = new WebSocketServer(httpServer, { path: "/ws" });
@@ -127,7 +128,9 @@ export function startServer({
}; };
const gameServer = new GameServer(network); 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() { function broadcastPlayers() {
const players = {}; 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` // Free, instant orders for local play; remote peers still have their `free`
// flag stripped in the order handler. // flag stripped in the order handler.
testing: process.argv.includes("--testing"), 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,
}); });
} }
+15 -6
View File
@@ -158,9 +158,9 @@ export const RESOURCE_RULES = {
steelPerPersonPerDay: 0.001, steelPerPersonPerDay: 0.001,
// Luxuries are rarer: 0.01 carat a person a day. // Luxuries are rarer: 0.01 carat a person a day.
luxuryPerPersonPerDay: 0.01, luxuryPerPersonPerDay: 0.01,
// Cities aim to hold 130% of a month's food before the month begins. // Cities aim to hold a month of every storable resource -- food, steel,
foodMonthlyBuffer: 1.3, // luxury and high-tech -- before the month begins.
daysPerMonth: 30, stockpileDays: 30,
// A city's baseline electric appetite, in kWh per euro of yearly GDP. This is // 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. // 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, unitSupplyDays: 50,
starvationHpFractionPerDay: 0.1, 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 // 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. // does the structure recover. A razed city level costs what it cost to build.
repair: { 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 // How far a city will look overland for a neighbour's surplus. Sea legs are not
// global market. // 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; export const RESOURCE_TRADE_RADIUS = 8;
// A region that can reach a food surplus it does not need buys extra to resell // 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, maxLevel: 12,
// How many days of sales the agent watches for the demand trend, and how much // 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. // 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, demandGrowth: 0,
// A producer that sells at least this share of what it makes is demand-bound // 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. // (it sells out), so it grows even when its sales look flat.
+18 -9
View File
@@ -14,18 +14,24 @@ import { RESOURCE_RULES, resourceById } from "./data/resources.js";
// Breadth-first spread from `start` over an abstract graph, in rings of // Breadth-first spread from `start` over an abstract graph, in rings of
// increasing distance. `neighbours(node)` returns the outward edges of a node, // increasing distance. `neighbours(node)` returns the outward edges of a node,
// each `{ node, cost }`; a directed graph is just a `neighbours` that omits the // each `{ node, cost, weight }`; a directed graph is just a `neighbours` that
// reverse edge. The traversal stops at `maxDistance` steps. // 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 // `accept(node, { distance, cost, weight })` is called for every newly reached
// (never the start) and decides whether it joins the result; returning false // node (never the start) and decides whether it joins the result; returning
// still lets the search continue through it. The result is the accepted nodes // false still lets the search continue through it. The result is the accepted
// with their accumulated `distance` (steps) and `cost` (sum of edge costs), // nodes with their accumulated `distance` (steps), `cost` (sum of edge costs)
// sorted by cost. // and `weight`, sorted by cost.
export function traverse(start, { neighbours, id = (node) => node, accept = null, maxDistance = Infinity }) { export function traverse(start, {
neighbours, id = (node) => node, accept = null,
maxDistance = Infinity, maxWeight = Infinity,
}) {
const results = []; const results = [];
const visited = new Set([id(start)]); 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) { while (frontier.length > 0) {
const next = []; const next = [];
for (const current of frontier) { 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)) { for (const edge of neighbours(current.node)) {
const key = id(edge.node); const key = id(edge.node);
if (visited.has(key)) continue; if (visited.has(key)) continue;
const weight = current.weight + (edge.weight === undefined ? 1 : edge.weight);
if (weight > maxWeight) continue;
visited.add(key); visited.add(key);
const reached = { const reached = {
node: edge.node, node: edge.node,
distance: current.distance + 1, distance: current.distance + 1,
cost: current.cost + (edge.cost || 0), cost: current.cost + (edge.cost || 0),
weight,
}; };
if (!accept || accept(edge.node, reached)) results.push(reached); if (!accept || accept(edge.node, reached)) results.push(reached);
next.push(reached); next.push(reached);
+96 -5
View File
@@ -53,6 +53,9 @@ import { budgetMethods } from "./game_state/budget.js";
import { moneyMethods } from "./game_state/money.js"; import { moneyMethods } from "./game_state/money.js";
import { taxMethods } from "./game_state/taxes.js"; import { taxMethods } from "./game_state/taxes.js";
// Shared empty result for `unitsAt`, so an empty tile does not allocate.
const EMPTY_UNITS = [];
export { export {
MAX_NEWS, MAX_NEWS,
UNIT_VISION, UNIT_VISION,
@@ -211,6 +214,23 @@ export class GameState {
this._cityNamesUsed = new Map(); this._cityNamesUsed = new Map();
this._usedCityNames = new Set(); this._usedCityNames = new Set();
this._unitById = new Map(); 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._cityById = new Map();
this._cityByCoords = new Map(); this._cityByCoords = new Map();
this._cityAdjacent = 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 // 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) { unitsAt(coords) {
const result = []; const bucket = this._unitsByTile && this._unitsByTile.get(key(coords.x, coords.y));
for (const unit of this.units) { if (bucket) return bucket;
if (unit.coords.x === coords.x && unit.coords.y === coords.y) result.push(unit); 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) { unitAt(coords) {
@@ -361,6 +435,23 @@ export class GameState {
this.tickHour(); 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() { tickHour() {
this.totalHours += 1; this.totalHours += 1;
// Natural growth is a whole number of days, so the cached production figures // Natural growth is a whole number of days, so the cached production figures
+1
View File
@@ -302,6 +302,7 @@ export const combatMethods = {
_destroyUnit(unit) { _destroyUnit(unit) {
const index = this.units.indexOf(unit); const index = this.units.indexOf(unit);
if (index >= 0) this.units.splice(index, 1); if (index >= 0) this.units.splice(index, 1);
this._unindexUnit(unit);
this._unitById.delete(unit.id); this._unitById.delete(unit.id);
}, },
+1
View File
@@ -37,6 +37,7 @@ export const diplomacyMethods = {
const k = warKey(actor, target); const k = warKey(actor, target);
if (this.wars.has(k)) return false; if (this.wars.has(k)) return false;
this.wars.add(k); this.wars.add(k);
this._invalidateSpatialCaches();
this.conflicts.set(k, { this.conflicts.set(k, {
a: Math.min(actor, target), a: Math.min(actor, target),
b: Math.max(actor, target), b: Math.max(actor, target),
+28 -9
View File
@@ -208,22 +208,23 @@ export const industryMethods = {
}; };
}, },
// How much of its capacity a converter should run at. It runs flat out while // How much of its capacity a converter should run at. Its output follows what
// its store is thin -- so it always keeps up with demand -- and only eases off // it has actually been selling across the whole watch window, not its nameplate
// once it is sitting on more stock than it has been selling, making roughly // capacity: a building ramps up only as demand is sustained and eases off again
// what it sells so it never burns power on goods it cannot move. A brand-new // when demand fades, so it never burns power on goods it cannot move. A
// building has no history and runs flat out. // brand-new building has no history and runs flat out.
_converterProductionScale(k, proto, agent) { _converterProductionScale(k, proto, agent) {
const capacity = resourceBuildingOutputAt(proto, agent ? agent.level : 0); const capacity = resourceBuildingOutputAt(proto, agent ? agent.level : 0);
if (!(capacity > 0)) return 1; 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) || []; const sales = (agent && agent.sales) || [];
let expected = capacity; let expected = capacity;
if (sales.length > 0) { if (sales.length > 0) {
const half = Math.max(1, Math.floor(sales.length / 2)); expected = sales.reduce((sum, value) => sum + value, 0) / sales.length;
const recent = sales.slice(-half);
expected = recent.reduce((sum, value) => sum + value, 0) / recent.length;
} }
const target = Math.max(capacity, expected * 2); const target = expected * 2;
const store = this.resourceStock ? this.resourceStock.get(k) : null; const store = this.resourceStock ? this.resourceStock.get(k) : null;
const stock = (store || {})[proto.resource] || 0; const stock = (store || {})[proto.resource] || 0;
if (stock <= target) return 1; if (stock <= target) return 1;
@@ -231,6 +232,24 @@ export const industryMethods = {
return Math.max(0.1, Math.min(1, expected / capacity)); 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 // 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 // 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 // nominal two-tile haul, so the quoted bill is not beaten by a distant
+1 -1
View File
@@ -218,7 +218,7 @@ export const movementMethods = {
hoursLeft -= needed; hoursLeft -= needed;
unit.pathIndex += 1; unit.pathIndex += 1;
unit.progressHours = 0; unit.progressHours = 0;
unit.coords = next; this._moveUnitTo(unit, next);
this._visibilityDirty = true; this._visibilityDirty = true;
this._onUnitArrived(unit); this._onUnitArrived(unit);
if (this._isAirUnit(unit)) { if (this._isAirUnit(unit)) {
+209 -56
View File
@@ -31,6 +31,7 @@ import {
steelNeedPerDay, steelNeedPerDay,
luxuryNeedPerDay, luxuryNeedPerDay,
foodMonthlyTarget, foodMonthlyTarget,
stockpileTarget,
producerEnergyPerDay, producerEnergyPerDay,
foodSynthesisEnergy, foodSynthesisEnergy,
synthesisedFoodFor, synthesisedFoodFor,
@@ -38,7 +39,7 @@ import {
protoUpkeep, protoUpkeep,
constructionResourceCost as sharedConstructionResourceCost, constructionResourceCost as sharedConstructionResourceCost,
} from "../resources.js"; } from "../resources.js";
import { deliveryEnergyForResource, traverse } from "../economy_graph.js"; import { deliveryEnergyKwh, deliveryEnergyForResource, traverse } from "../economy_graph.js";
import { buildingBuildCost } from "../rules.js"; import { buildingBuildCost } from "../rules.js";
import { Random } from "../rng.js"; import { Random } from "../rng.js";
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.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 // Whether a city can reach another continent's markets: its own continent
// holds a working friendly port. No working port, no foreign resources. // holds a working friendly port. No working port, no foreign resources.
_cityMarketAccess(city) { _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); const continent = this._continentOf(city.coords);
let result = false;
for (const port of this.cities) { for (const port of this.cities) {
if (port.civ !== city.civ) continue; if (port.civ !== city.civ) continue;
if (this._continentOf(port.coords) !== continent) 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) { _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) { for (const city of this.cities) {
if (city.civ !== civ) continue; 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 // 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.resourceTrade = new Map();
this.resourceExternal = new Map(); this.resourceExternal = new Map();
this._resourceShortages = 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(); 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(); const market = new Map();
for (const id of RESOURCE_IDS) market.set(id, { supply: 0, demand: 0, price: this.getResourcePrice(id) }); for (const id of RESOURCE_IDS) market.set(id, { supply: 0, demand: 0, price: this.getResourcePrice(id) });
this.resourceMarketStats = market; this.resourceMarketStats = market;
@@ -1405,6 +1429,7 @@ export const resourceMethods = {
coords: city.coords, coords: city.coords,
component: components.get(k), component: components.get(k),
amount: produced, amount: produced,
source: "harvest",
}); });
const component = ledger.get(components.get(k)); const component = ledger.get(components.get(k));
if (component) { if (component) {
@@ -1418,7 +1443,10 @@ export const resourceMethods = {
// extra food from energy -- greenhouses and synthesis. Growing is cheapest // extra food from energy -- greenhouses and synthesis. Growing is cheapest
// for the first tonnes, so the region grows while the marginal energy for // for the first tonnes, so the region grows while the marginal energy for
// another tonne beats buying, then completes with the dearer option: what // 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 foodPrice = this.getResourcePrice("food");
const energyPrice = this.getResourcePrice("energy"); const energyPrice = this.getResourcePrice("energy");
const nodes = this._resourceNodes(); const nodes = this._resourceNodes();
@@ -1426,7 +1454,13 @@ export const resourceMethods = {
for (const city of this.cities) { for (const city of this.cities) {
const produced = regionFood.get(city.id) || 0; const produced = regionFood.get(city.id) || 0;
const need = foodNeedPerDay(this.getCityEconomy(city).population); 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; if (!(shortfall > 0)) continue;
const allowSea = this._cityMarketAccess(city) && const allowSea = this._cityMarketAccess(city) &&
!this._isEncircled(city.coords, city.civ); !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. // the market wins; the energy it actually spends falls by the same share.
const foodEfficiency = 1 + this.resourceEfficiency(city.civ, "food"); const foodEfficiency = 1 + this.resourceEfficiency(city.civ, "food");
const extra = synthesisedFoodFor( const extra = synthesisedFoodFor(
foodPrice, energyPrice / foodEfficiency, shortfall, buyable foodPrice, energyPrice / foodEfficiency, shortfall, buyable, mustGrow
); );
if (!(extra > 0)) continue; if (!(extra > 0)) continue;
const componentId = components.get(key(city.coords.x, city.coords.y)); const componentId = components.get(key(city.coords.x, city.coords.y));
@@ -1454,6 +1488,7 @@ export const resourceMethods = {
coords: city.coords, coords: city.coords,
component: componentId, component: componentId,
amount: extra, amount: extra,
source: "synthesis",
}); });
this._foodSynthesis.set(city.id, extra); this._foodSynthesis.set(city.id, extra);
} }
@@ -1470,23 +1505,27 @@ export const resourceMethods = {
for (const component of ledger.values()) { for (const component of ledger.values()) {
if (component.civ < 0) continue; if (component.civ < 0) continue;
component.available = component.supply + component.imports; component.available = component.supply + component.imports;
// Cities are served first, then the food the region grows, then the // The harvest is served first: it is a sliver of the grid next to the
// converters: a famine is worse than an idle factory, so the power that // cities' own draw, but a shortfall is a famine while an under-served city
// feeds people is never throttled away by industry. // only dims. The cities come next, then the converters: an idle factory is
const cityServed = Math.min(component.cityEnergy, component.available); // 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 component.cityShortage = component.cityEnergy > 0
? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy)) ? Math.max(0, Math.min(1, (component.cityEnergy - cityServed) / component.cityEnergy))
: 0; : 0;
const afterCity = Math.max(0, component.available - cityServed); const afterCity = afterFood - 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 component.industrialScale = component.converterEnergy > 0
? Math.max(0, Math.min(1, afterFood / component.converterEnergy)) ? Math.max(0, Math.min(1, afterCity / component.converterEnergy))
: 1; : 1;
market.get("energy").supply += component.supply; market.get("energy").supply += component.supply;
market.get("energy").demand += component.demand; 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. // A grid that cannot power its industry names the works left idle.
this._noteGridPowerShortages(ledger, components); this._noteGridPowerShortages(ledger, components);
@@ -1502,6 +1541,9 @@ export const resourceMethods = {
this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced; this._ensureResourceStock(key(entry.coords.x, entry.coords.y)).food += produced;
market.get("food").supply += produced; market.get("food").supply += produced;
grownFood.set(entry.cityId, (grownFood.get(entry.cityId) || 0) + 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 // 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. // stores -- home regions first, then foreign ones.
this._consumeCityResources(components, market, nodes); 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, // 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. // after the day's sales so an agent can pay from what it just earned.
this._settleGridPower(ledger); this._settleGridPower(ledger);
@@ -1565,6 +1612,11 @@ export const resourceMethods = {
this._applyResourceShortages(ledger); this._applyResourceShortages(ledger);
this._pruneResourceStock(); 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 // 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 // supply searches all route around these tiles, so moving a unit in forces
// the next recalculation to find another way -- or none. // the next recalculation to find another way -- or none.
_hostileOccupied(civ) { _hostileOccupied(civ) {
const cache = this._hostileOccupiedCache;
if (cache && cache.has(civ)) return cache.get(civ);
const blocked = new Set(); const blocked = new Set();
for (const unit of this.units) { for (const unit of this.units) {
if (unit.civ === civ) continue; if (unit.civ === civ) continue;
@@ -1791,6 +1845,7 @@ export const resourceMethods = {
if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue; if (!this._isMilitary(unit) || this._isAirUnit(unit)) continue;
blocked.add(key(unit.coords.x, unit.coords.y)); blocked.add(key(unit.coords.x, unit.coords.y));
} }
if (cache) cache.set(civ, blocked);
return blocked; return blocked;
}, },
@@ -1805,8 +1860,9 @@ export const resourceMethods = {
const blocked = this._hostileOccupied(city.civ); const blocked = this._hostileOccupied(city.civ);
const visited = new Set([origin]); const visited = new Set([origin]);
const queue = [{ coords: city.coords, energy: 0 }]; const queue = [{ coords: city.coords, energy: 0 }];
while (queue.length > 0) { let head = 0;
const current = queue.shift(); while (head < queue.length) {
const current = queue[head++];
for (const neighbour of this._neighbours(current.coords)) { for (const neighbour of this._neighbours(current.coords)) {
const nk = key(neighbour.x, neighbour.y); const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk) || blocked.has(nk)) continue; if (visited.has(nk) || blocked.has(nk)) continue;
@@ -1902,6 +1958,16 @@ export const resourceMethods = {
return (store.food || 0) / need; return (store.food || 0) / need;
}; };
const order = this.cities.slice().sort((a, b) => urgency(a) - urgency(b)); 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) { for (const city of order) {
const population = this.getCityEconomy(city).population; const population = this.getCityEconomy(city).population;
const needs = { const needs = {
@@ -1911,7 +1977,6 @@ export const resourceMethods = {
}; };
const store = this.getCityResourceStock(city); const store = this.getCityResourceStock(city);
const shortage = {}; const shortage = {};
const foodTarget = foodMonthlyTarget(population);
// An encircled city is cut off from neighbours and the foreign market: // 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 // 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. // 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); const own = Math.min(store[id] || 0, need);
store[id] = (store[id] || 0) - own; store[id] = (store[id] || 0) - own;
const dailyShortfall = need - own; const dailyShortfall = need - own;
// Food is stocked ahead: the city tops its store up to 130% of the // A pantry below its month is a standing bid: the region wants to
// coming month's needs, not just today's. Steel and luxury are bought // refill the reserve, so even a day the meal is covered prices food up
// as they are eaten. // while the store is thin. Spread over the reserve horizon, so an empty
const wanted = id === "food" // store bids at most a second helping of the daily need.
? Math.max(dailyShortfall, foodTarget - store[id]) if (id === "food") {
: dailyShortfall; 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; let bought = 0;
if (wanted > 0 && !cutOff) { if (wanted > 0 && !cutOff) {
bought = this._procureFromNeighbours(city, id, wanted, store, nodes, { allowSea }); bought = this._procureFromNeighbours(city, id, wanted, store, nodes, { allowSea });
} }
// What was bought against today's need is eaten today; only the food // What was bought against today's need is eaten today; the buffer left
// buffer left over stays in the store. // over stays in the store.
const consumed = Math.min(bought, dailyShortfall); const consumed = Math.min(bought, dailyShortfall);
if (consumed > 0) store[id] -= consumed; if (consumed > 0) store[id] -= consumed;
if (dailyShortfall > bought + 0.0001 && need > 0) { if (dailyShortfall > bought + 0.0001 && need > 0) {
shortage[id] = (dailyShortfall - bought) / need; 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); 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 // 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 // order of transport energy. A breadth-first search over land and sea, with
// at RESOURCE_TRADE_RADIUS tiles. `routeEnergyPerTonne` is the accumulated // land capped at `RESOURCE_TRADE_RADIUS` tiles and sea lanes running any
// kWh to move one tonne from the city to that node. // distance. `routeEnergyPerTonne` is the accumulated kWh to move one tonne
// from the city to that node.
_nearbySuppliers(city, id, nodes, allowSea = true) { _nearbySuppliers(city, id, nodes, allowSea = true) {
return this._tradeReachable(city, nodes, allowSea, (node) => this._nodeSurplus(node, id) > 0, id); 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 // 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 // breadth-first spread over land and sea that hands every reachable node to
// tiles, that hands every reachable node to `accept`. `resource` only names // `accept`. Land legs are capped at `RESOURCE_TRADE_RADIUS` tiles, but a
// the commodity charged for the haul; the transport rules themselves are // shipping lane between ports carries goods any distance, so only land counts
// commodity-independent, so the delivery graph passes one to price its rides. // against the cap. `resource` only names the commodity charged for the haul;
// The traversal itself lives in the economy-graph toolkit; this method only // the transport rules themselves are commodity-independent, so the delivery
// describes the network it walks. // 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") { _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 byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node]));
const origin = key(city.coords.x, city.coords.y); const origin = key(city.coords.x, city.coords.y);
const blocked = this._hostileOccupied(city.civ); const blocked = this._hostileOccupied(city.civ);
const reached = traverse(city.coords, { const reached = traverse(city.coords, {
id: (coords) => key(coords.x, coords.y), id: (coords) => key(coords.x, coords.y),
maxDistance: RESOURCE_TRADE_RADIUS, maxWeight: RESOURCE_TRADE_RADIUS,
neighbours: (coords) => this._tradeNeighbours(city, coords, allowSea, resource, blocked), neighbours: (coords) => this._tradeNeighbours(city, coords, allowSea, blocked),
accept: (coords) => { accept: (coords) => {
const here = byKey.get(key(coords.x, coords.y)); const here = byKey.get(key(coords.x, coords.y));
if (!here || key(here.coords.x, here.coords.y) === origin) return false; 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); 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)), ...byKey.get(key(entry.node.x, entry.node.y)),
tiles: entry.distance, tiles: entry.distance,
routeEnergyPerTonne: entry.cost, routeEnergyPerTonne: entry.cost,
})); }));
cache.set(cacheKey, list);
return list;
}, },
// The passable outward edges of one tile for the trade network: land legs // 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 // 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 // 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 // edge is priced in the transport energy one tonne burns over it; the caller
// it, so the graph is commodity-aware while the traversal is not. // scales by the commodity's mass, so every commodity shares one graph.
_tradeNeighbours(city, coords, allowSea, resource, blocked) { _tradeNeighbours(city, coords, allowSea, blocked) {
const edges = []; const edges = [];
for (const neighbour of this._neighbours(coords)) { for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y); const nk = key(neighbour.x, neighbour.y);
@@ -2254,7 +2370,10 @@ export const resourceMethods = {
} }
edges.push({ edges.push({
node: neighbour, 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; return edges;
@@ -2302,7 +2421,7 @@ export const resourceMethods = {
_nodeSurplus(node, id) { _nodeSurplus(node, id) {
if (id === "hightech") return Math.max(0, node.stock.hightech || 0); 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" const perDay = id === "food"
? foodNeedPerDay(population) ? foodNeedPerDay(population)
: id === "steel" : id === "steel"
@@ -2313,6 +2432,19 @@ export const resourceMethods = {
return Math.max(0, (node.stock[id] || 0) - perDay * SUPPLIER_RESERVE_DAYS); 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 // A shortage of food, steel or luxury costs popularity; a grid starved of
// power costs popularity too. Each city is charged once per day. // power costs popularity too. Each city is charged once per day.
_applyResourceShortages(ledger) { _applyResourceShortages(ledger) {
@@ -2487,8 +2619,10 @@ export const resourceMethods = {
const blocked = this._hostileOccupied(civ); const blocked = this._hostileOccupied(civ);
const visited = new Set([key(unit.coords.x, unit.coords.y)]); const visited = new Set([key(unit.coords.x, unit.coords.y)]);
const queue = [unit.coords]; const queue = [unit.coords];
while (queue.length > 0) { let head = 0;
const coords = queue.shift(); let marketAccess = null;
while (head < queue.length) {
const coords = queue[head++];
for (const neighbour of this._neighbours(coords)) { for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y); const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk) || blocked.has(nk)) continue; if (visited.has(nk) || blocked.has(nk)) continue;
@@ -2497,7 +2631,8 @@ export const resourceMethods = {
const sea = tile.terrainClass === "Sea"; const sea = tile.terrainClass === "Sea";
if (sea) { if (sea) {
// A shipping lane needs a working port at the nation's end. // 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))) { } else if (!(this.railways.has(nk) || this.roads.has(nk))) {
continue; continue;
} }
@@ -2553,6 +2688,30 @@ export const resourceMethods = {
this._recordResourceSpend(civ, "upkeep", spent, "food"); 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 // 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 // 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 // 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); const proto = this.unitProto(unit);
if (!proto || !this._isLandUnitProto(proto)) continue; if (!proto || !this._isLandUnitProto(proto)) continue;
const ration = unitFoodPerDay(proto); const ration = unitFoodPerDay(proto);
const here = this.cityAt(unit.coords); const multiplier = this._unitFoodMultiplier(unit, battle);
const resting = !!(here && here.civ === unit.civ && unit.hp < unit.maxHp);
const multiplier = resting
? RESOURCE_RULES.infantryRestingFoodMultiplier
: battle.has(unit.id)
? RESOURCE_RULES.infantryCombatFoodMultiplier
: 1;
const city = this._unitSupplyCity(unit); const city = this._unitSupplyCity(unit);
const fed = city ? this._provisionUnit(unit, city, ration * multiplier) : false; const fed = city ? this._provisionUnit(unit, city, ration * multiplier) : false;
if (fed) { if (fed) {
+34 -4
View File
@@ -325,12 +325,42 @@ export const warfareMethods = {
this._emitChanged(); 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) { _unitsWithin(coords, radius) {
const result = []; const result = [];
const origin = { x: coords.x, y: coords.y }; if (!(radius >= 0)) return result;
for (const unit of this.units) { if (!this._unitsByTile) {
if (this.topology.tileDistance(origin, unit.coords) <= radius) result.push(unit); 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; return result;
}, },
+1
View File
@@ -258,6 +258,7 @@ export const worldMethods = {
} }
this.units.push(unit); this.units.push(unit);
this._unitById.set(this._nextUnitId, unit); this._unitById.set(this._nextUnitId, unit);
this._indexUnit(unit);
this._nextUnitId += 1; this._nextUnitId += 1;
return unit; return unit;
}, },
+26 -14
View File
@@ -83,12 +83,17 @@ export function tileFoodOutput(tile) {
return RESOURCE_RULES.foodPerTilePerDay * factor; 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 // 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) { export function foodMonthlyTarget(population) {
return foodNeedPerDay(population) * return stockpileTarget(foodNeedPerDay(population));
RESOURCE_RULES.daysPerMonth *
RESOURCE_RULES.foodMonthlyBuffer;
} }
// The energy a region spends to force `extraFood` tonnes a day from the land // 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); return 2 * RESOURCE_RULES.foodSynthesisQuadratic * Math.max(0, extraFood);
} }
// How much extra food a region grows for a `shortfall` it must cover. Growing // How much extra food a region grows for a `shortfall` it can cover. Growing is
// is always cheapest for the first tonnes (its marginal energy starts near // always cheapest for the first tonnes (its marginal energy starts near zero)
// zero) and buying is a flat price, so the region grows while the marginal // and buying is a flat price, so the region grows while the marginal energy is
// energy is cheaper than the market, up to the break-even. Then it completes // cheaper than the market, up to the break-even. Then it completes with the
// with the dearer option: what the reachable market cannot actually supply -- // dearer option: what the reachable market cannot actually supply -- `buyable`
// `buyable` tonnes -- it must grow itself, even past the break-even. With an // tonnes -- it must grow itself, even past the break-even. With an unreachable
// unreachable or bottomless market the default `buyable = Infinity` gives the // or bottomless market the default `buyable = Infinity` gives the plain
// plain break-even result. // break-even result.
export function synthesisedFoodFor(foodPrice, energyPrice, shortfall, buyable = Infinity) { //
// `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); const gap = Math.max(0, shortfall);
if (!(gap > 0)) return 0; 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; const quadratic = RESOURCE_RULES.foodSynthesisQuadratic;
if (!(energyPrice > 0) || !(quadratic > 0) || !(foodPrice > 0)) { if (!(energyPrice > 0) || !(quadratic > 0) || !(foodPrice > 0)) {
return Math.min(gap, forced); return Math.min(gap, forced);
+44
View File
@@ -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");
}
}
+88
View File
@@ -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"
);
}
}
+25 -2
View File
@@ -7,6 +7,7 @@ import {
tileFoodOutput, tileFoodOutput,
foodSynthesisEnergy, foodSynthesisEnergy,
foodSynthesisMarginalEnergy, foodSynthesisMarginalEnergy,
stockpileTarget,
synthesisedFoodFor, synthesisedFoodFor,
} from "../shared/resources.js"; } 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; if (coords.x === city.coords.x && coords.y === city.coords.y) continue;
state.tilePopulation.set(key(coords.x, coords.y), 0); state.tilePopulation.set(key(coords.x, coords.y), 0);
} }
const shortfall = foodNeedPerDay(state.getCityEconomy(city).population) - const need = foodNeedPerDay(state.getCityEconomy(city).population);
regionFood(state, city); const shortfall = need - regionFood(state, city);
this.assertGreater(shortfall, 0, "the fixture region opens short of food"); 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. // Cheap energy against a dear market: force as much as the shortfall allows.
state.setResourcePrice("energy", 0.00001); state.setResourcePrice("energy", 0.00001);
state.setResourcePrice("food", 1e9); state.setResourcePrice("food", 1e9);
@@ -103,6 +107,25 @@ export class FoodSynthesisModelTest extends TestCase {
this.assertApprox(extra, shortfall, 1, "the whole shortfall is grown"); 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() { test_a_region_buys_instead_when_energy_is_dearer_than_food() {
const state = smallState(); const state = smallState();
const city = cityOf(state, 0); const city = cityOf(state, 0);
+31
View File
@@ -255,4 +255,35 @@ export class PowerMarketTest extends TestCase {
"selling out is a reason to grow" "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"
);
}
} }
+7 -5
View File
@@ -43,10 +43,10 @@ export class MoneyTest extends TestCase {
const buyer = cityOf(state, 0); const buyer = cityOf(state, 0);
const seller = cityOf(state, 1); const seller = cityOf(state, 1);
const sellerCode = state.currencyOf(1).code; const sellerCode = state.currencyOf(1).code;
// Empty every home store so only the foreign region can supply. // Empty every store so only the intended foreign region can supply: with a
for (const city of state.cities) { // sea lane running any distance, a nearer home or foreign one would be
if (city.civ === 0) state.getCityResourceStock(city).food = 0; // bought from first.
} for (const city of state.cities) state.getCityResourceStock(city).food = 0;
state.getCityResourceStock(seller).food = 1.0e9; state.getCityResourceStock(seller).food = 1.0e9;
const before = state.getRegionCash(seller).get(sellerCode) || 0; const before = state.getRegionCash(seller).get(sellerCode) || 0;
const payer = state._treasuryPayer(0); const payer = state._treasuryPayer(0);
@@ -216,8 +216,10 @@ export class MoneyTest extends TestCase {
const state = smallState(); const state = smallState();
const buyer = cityOf(state, 0); const buyer = cityOf(state, 0);
const seller = cityOf(state, 1); 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(seller).food = 1e9;
state.getCityResourceStock(buyer).food = 0;
const buyerCode = state.currencyOf(0).code; const buyerCode = state.currencyOf(0).code;
const sellerCode = state.currencyOf(1).code; const sellerCode = state.currencyOf(1).code;
const buyerBefore = state.getRegionCash(buyer).get(buyerCode) || 0; const buyerBefore = state.getRegionCash(buyer).get(buyerCode) || 0;
+8 -8
View File
@@ -61,8 +61,8 @@ export class ResourceRulesTest extends TestCase {
this.assertApprox(luxuryNeedPerDay(2_000_000), 20_000); this.assertApprox(luxuryNeedPerDay(2_000_000), 20_000);
} }
test_food_target_is_a_month_with_a_cushion() { test_food_target_is_a_flat_month() {
const need = foodNeedPerDay(2_000_000) * 30 * RESOURCE_RULES.foodMonthlyBuffer; const need = foodNeedPerDay(2_000_000) * RESOURCE_RULES.stockpileDays;
this.assertApprox(foodMonthlyTarget(2_000_000), need); this.assertApprox(foodMonthlyTarget(2_000_000), need);
} }
@@ -332,11 +332,9 @@ export class ResourceModelTest extends TestCase {
state.budgets.set(0, 1e15); state.budgets.set(0, 1e15);
const city = cityOf(state, 0); const city = cityOf(state, 0);
const seller = cityOf(state, 1); const seller = cityOf(state, 1);
// Every city of the nation must be empty, or the draw would find their // Empty every other store, or a nearer home or foreign seller would be
// stores before it reaches a foreign region. // reached first now that a port connects the whole coast.
for (const own of state.cities) { for (const own of state.cities) state.getCityResourceStock(own).steel = 0;
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
}
state.getCityResourceStock(seller).steel = 1.0e9; state.getCityResourceStock(seller).steel = 1.0e9;
const before = state.getBudget(0); const before = state.getBudget(0);
const ok = state._payConstructionResources(0, city.coords, { steel: 1000, hightech: 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); state.budgets.set(0, 1e15);
const buyer = cityOf(state, 0); const buyer = cityOf(state, 0);
const seller = cityOf(state, 1); 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(seller).food = 1e9;
state.getCityResourceStock(buyer).food = 0;
const bought = state._procureFromNeighbours( const bought = state._procureFromNeighbours(
buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes() buyer, "food", 1000, state.getCityResourceStock(buyer), state._resourceNodes()
); );
+3 -1
View File
@@ -76,7 +76,9 @@ export class ServerStaticTest extends TestCase {
async test_cli_starts_and_reports_its_url() { async test_cli_starts_and_reports_its_url() {
const script = fileURLToPath(new URL("../server/server.js", import.meta.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 { try {
const line = await new Promise((resolve, reject) => { const line = await new Promise((resolve, reject) => {
let out = ""; let out = "";
+66
View File
@@ -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");
}
}
+36
View File
@@ -102,4 +102,40 @@ export class TradeGraphTest extends TestCase {
this.assertEmpty(graph.links); this.assertEmpty(graph.links);
this.assertEmpty(graph.edges); 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");
}
} }