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:
@@ -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)://<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):
|
||||
`./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`,
|
||||
|
||||
+13
@@ -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`,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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) || "");
|
||||
|
||||
+7
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+18
-9
@@ -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);
|
||||
|
||||
+96
-5
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+209
-56
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
+26
-14
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user