Compare commits

...
3 Commits
Author SHA1 Message Date
adrien 822c26f616 Founded every nation with a standing army and a year of reserves 2026-09-23 22:41:44 +02:00
adrien 0d1995c0bb Stopped a tile-work rebuild from discarding every cached trade walk
The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms.

The snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit.

Together a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once.
2026-09-23 22:22:50 +02:00
adrien 80429918c6 Made producers pay their own way and settled the world to steady prices
- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too.
- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.
- Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense.
- The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it).
- The shipped month report carries its label again, so the budget header and pager name the month.
2026-09-23 21:45:28 +02:00
33 changed files with 1165 additions and 206 deletions
+42 -10
View File
@@ -19,10 +19,11 @@ 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`. 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.
connects to `ws(s)://<page-origin>/ws`. Before it listens the server settles
the world until commodity prices hold steady over a seven-day window, so the
economy opens established; pass `--warmup <days>` (or set `WARMUP`) to cap how
many days the settle may run and `--warmup 0` to skip it. The settle logs how
many days it took and usually stops well short of the cap 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`.
@@ -78,6 +79,21 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
their strikes are routed explicitly. Mirror the air rules (airport-only
rebasing, strike radius, ferry fuel) on both client and server, or prediction
and the authoritative state will disagree.
- Keep the snapshot path cheap. `_broadcastState` rebuilds the shared snapshot
and every viewer's stats whenever the state is dirty (orders, hourly ticks),
not only when the economy moves, so a walk that touches every tile (headline
population/GDP, ethnic makeup, transport counts, a region's tiles) must be
memoised against the counters that actually change it: `_gdpEpoch`
(`_clearTileGdpCache`/`_dropTileGdpCache`), `_populationVersion`,
`_territoryVersion`, `_modifiersVersion`, `_tileImprovementVersion`. Do not
cache a whole enemy/own region's walk on nothing, and do not memoise
`getCityEconomy` outside a snapshot -- a test pins that a simulation read
reflects a direct `tilePopulation` edit at once.
- `DELTA_COLLECTIONS` in `server/game_server.js` pairs each large collection
with the `versions` key that reports it (note `tileEthnicity` is versioned as
`ethnicity`). If the pair drifts, the whole collection is silently shipped on
every broadcast -- the opening production baseline is static, so getting this
wrong re-sent thousands of entries ten times a second.
## Architecture
@@ -135,13 +151,18 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
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
walk under a stamp built from the road/railway, territory and storage-node
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). The tile-improvement version is deliberately *not* in the stamp: a
mine, mill or plant rebuilt in place bumps it many times a day but moves no
route, and folding it in threw every city's cached walk away each time. 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).
`GameState.warmUp(days)` runs a fixed number of days without players, while
`warmUpToStability({ maxDays })` settles until `pricesAreSteady` (every
commodity within 10% over the trailing seven days) or the cap, and the server
calls the latter 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
@@ -186,3 +207,14 @@ There is no linter, formatter, or CI. Do not invent commands beyond these.
`git pull` and `systemctl restart battle-for-tismo`. Keep the client and
server trees on the same commit: the client mirrors the server's movement
model, so a mismatched pair makes prediction fight the server.
<!-- CODEGRAPH_START -->
## CodeGraph
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
- **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search.
- **Shell** (always works): `codegraph explore "<symbol names or question>"` prints the same output.
If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
<!-- CODEGRAPH_END -->
+2
View File
@@ -1707,6 +1707,8 @@ input:focus, select:focus { border-color: var(--accent); }
.budget-caret { display: inline-block; width: 12px; color: var(--muted); }
.budget-note { margin-top: 2px; color: var(--muted); font-size: 11px; }
.budget-table tr.budget-buy .budget-name { padding-left: 26px; }
.budget-table .res { display: inline; white-space: nowrap; }
.budget-table .res img { width: 13px; height: 13px; vertical-align: -2px; margin-right: 3px; }
.budget-table tr.budget-source .budget-name { padding-left: 44px; color: var(--muted); }
.budget-table tr.budget-source .budget-value { color: var(--muted); }
.budget-table tfoot .budget-total td {
+18 -18
View File
@@ -1,6 +1,24 @@
// 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": "0d1995c",
"date": "2026-09-23",
"subject": "Stopped a tile-work rebuild from discarding every cached trade walk",
"body": "The trade graph's cache stamp mixed in the tile-improvement version, which a mine, mill or plant rebuilt in place bumps many times a day, so every city's sea-lane flood was thrown away and rebuilt. The stamp now keys on the transport network, the territory and the storage-node set alone: over ten days the rebuilds fall from hundreds to the 45 the world needs, and the daily resource tick from about 700 ms to about 150 ms.\n\nThe snapshot path is memoised against the counters that actually move it -- the per-civ headline population and GDP, the per-city building and per-nation government/research modifiers, the port and food bonuses, the road and railway tile counts and the ethnic make-up rebuild only when their version changes; the flattened visible set is cached alongside explored; and the HUD upkeep sums without building the labelled budget breakdown. DELTA_COLLECTIONS had drifted from the snapshot's key names (it listed gdpBaseline while the state ships productionBaseline, and tileEthnicity while the version is ethnicity), so the static baseline was re-sent as JSON on every broadcast; the collection/version pairs are now explicit.\n\nTogether a broadcast falls from roughly 60 ms to 18 ms and the bytes for fifty of them from 57 MB to 24 MB, and 200 in-game hours simulate in about a third of the time. regionTiles caches the per-city owned lists behind the daily economy walks, and supplier reserves read a region's headcount without deriving its GDP; getCityEconomy itself stays uncached outside a snapshot so a simulation read still reflects a direct population edit at once."
},
{
"hash": "8042991",
"date": "2026-09-23",
"subject": "Made producers pay their own way and settled the world to steady prices",
"body": "- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too.\n- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.\n- Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense.\n- The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it).\n- The shipped month report carries its label again, so the budget header and pager name the month."
},
{
"hash": "31683be",
"date": "2026-09-23",
"subject": "Rolled the commodity index over ninety days and gave the market an equilibrium",
"body": "The central bank now charts one point a day for a rolling ninety days, shipped rebased to 100, with alternating month columns named once along the bottom so no two dates collide; short games leave future days blank and the axis floor is pinned at 0 with the 100 baseline labelled. The market no longer clamps a price to 0.5-1.8x base: the day's supply and demand set an equilibrium (base * demand/supply) and the price eases toward it, so a sustained shortage lifts a good well past its old band while a glut cheapens it, and the price settles instead of compounding to infinity. The nation modal holds each scroll position across a snapshot rebuild so the foreign reserves below the fold stay put."
},
{
"hash": "7dbbe86",
"date": "2026-09-23",
@@ -42,23 +60,5 @@ export const DEVLOG = [
"date": "2026-09-23",
"subject": "Rolled the central bank's commodity chart over the last two years",
"body": "The index is still 100 at January 2000's prices, but the chart now plots only the last twenty-four monthly samples and its end labels follow the window, so the graph stops growing as the game runs. A test covers the 24-month window and its end labels."
},
{
"hash": "2d36685",
"date": "2026-09-22",
"subject": "Moved tile works into a management panel and put every building on one card",
"body": "The city buildings tab and the HUD's tile panel now draw the same card: an icon, name and status, a bottom-anchored button carrying the estimated price and build time, a quiet remove control and a hatched locked state, with the produce/consume/cost detail in a hover tooltip. The shared card lives in client/js/ui/card.js.\n\nThe tile dock is now a summary — description, production, people and their ethnic makeup, GDP and the improvements list — with a Management button that opens a full-size panel like the city and nation panels. Its Improvements tab holds the grouped Military, Transportation and Economic cards, so Road and Railway sit with the other works. City buildings sit in a two-row horizontal scroller with the built ones first, and the city panel now matches the nation panel's size.\n\nAGENTS.md now asks for a minimal visual preview to be shown before a player-visible change is committed."
},
{
"hash": "7ea5373",
"date": "2026-09-22",
"subject": "Moved the economy onto resources and surfaced each building's material bill",
"body": "Rebalanced the resource rules so nothing is priced primarily in money: steel, electricity and high-tech lead every construction bill, chip foundries are seeded everywhere instead of being hand-placed, and cities open with stores of steel and high-tech. City building rows now lead with the resource bill and keep the money as an aside, and the region's resources expand into the buildings that demand them. Pulled the graph and cost maths into a framework-free shared/economy_graph.js, quoted currency through one helper, and gave every data table the same striping and row highlight."
},
{
"hash": "1324128",
"date": "2026-09-22",
"subject": "Showed idle trade routes and flagged migrant flows on the economic maps",
"body": "The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them.\n\nThe population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph."
}
];
+3 -1
View File
@@ -1078,7 +1078,9 @@ export const panelMethods = {
}
$el.append(
$("<div class='site-material-label'></div>").text(
`Budget ${currencySymbol()}${compact(site.budget || 0)}`
site.private
? "Private work — the owner's cash and credit buy the materials"
: `Budget ${currencySymbol()}${compact(site.budget || 0)}`
)
);
$el.append(
+20 -4
View File
@@ -4,9 +4,10 @@
// per-city cost. `breakdown` mirrors the shape both the national and the
// regional economy use, so one renderer serves them both.
import { percent, signedMoney, money, SOURCE_LABELS } from "./format.js";
import { percent, signedMoney, money, unitMoney, SOURCE_LABELS } from "./format.js";
import { BUDGET_EXPENSE_CATEGORIES, resourceById } from "../../../shared/data.js";
import { groupDigits } from "../../../shared/text_format.js";
import { resourceIcon } from "../ui/card.js";
import { currencySymbol } from "../currency.js";
// Draws the table into `$list`. `expanded` is the set of open building types,
@@ -172,10 +173,23 @@ export function renderMonthlyBudget($list, report, { expanded = new Set(), onTog
$body.append($tr);
if (isOpen) {
for (const buy of row.buys) {
const [resource, amount, sources] = buy;
const [resource, amount, sources, quantity = 0] = buy;
const proto = resourceById(resource);
const name = proto ? proto.name : resource;
$body.append(monthRow(name, -amount, "expense-detail budget-buy", null));
const unit = proto ? proto.unit : "";
// The money is the total bill; the quantity and the average delivered
// price the money bought it at is what shows the cost of the work.
const $label = $("<span class='res'></span>");
$label.append(resourceIcon(resource));
if (quantity > 0) {
$label.append(document.createTextNode(
`${groupDigits(Math.round(quantity))}${unit ? ` ${unit}` : ""}` +
` @ ${unitMoney(amount / quantity)}${unit ? `/${unit}` : ""} avg`
));
} else {
$label.append(document.createTextNode(name));
}
$body.append(monthRow($label, -amount, "expense-detail budget-buy", null));
for (const [source, sourceAmount] of sources || []) {
$body.append(
monthRow(source, -sourceAmount, "expense-detail budget-source", null)
@@ -208,7 +222,9 @@ export function renderMonthlyBudget($list, report, { expanded = new Set(), onTog
function monthRow(label, value, cls, note) {
const $tr = $("<tr></tr>").addClass(cls);
const $name = $("<td class='budget-name'></td>").text(label);
const $name = $("<td class='budget-name'></td>");
if (typeof label === "string") $name.text(label);
else $name.append(label);
if (note) $name.append($("<div class='budget-note'></div>").text(note));
$tr.append($name, $("<td class='budget-value'></td>").text(money(value)));
return $tr;
+11 -1
View File
@@ -3,7 +3,7 @@
import { groupDigits } from "../../../shared/text_format.js";
import { policyById, resourceById } from "../../../shared/data.js";
import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js";
import { formatMoneySigned } from "../currency.js";
import { formatMoneySigned, currencySymbol } from "../currency.js";
import { researchNode } from "../ui/card.js";
// The world market's current prices, from the snapshot's market listing.
@@ -131,6 +131,16 @@ export function money(value) {
return formatMoneySigned(value);
}
// A per-unit price. Ordinary figures round to the nearest unit of currency;
// sub-unit prices (a kilowatt-hour, say) keep significant digits so the row
// does not read as free.
export function unitMoney(value) {
const amount = Number(value) || 0;
if (amount >= 100) return `${currencySymbol()}${groupDigits(Math.round(amount))}`;
if (amount >= 1) return `${currencySymbol()}${amount.toFixed(1)}`;
return `${currencySymbol()}${amount.toPrecision(3)}`;
}
export function percent(value) {
return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`;
}
+9
View File
@@ -12,6 +12,15 @@ import { formatMoney } from "../currency.js";
// The materials a construction bills in, in reading order.
export const CARD_RESOURCES = ["steel", "energy", "hightech"];
// A commodity's icon as an image node, for a row that names a resource with a
// picture rather than the word.
export function resourceIcon(id) {
const proto = resourceById(id);
const $img = $("<img>");
if (proto) $img.attr("src", `assets/${proto.icon}`).attr("alt", proto.name);
return $img;
}
// A resource amount with its commodity icon. `sign` marks the direction of the
// flow: "+" for output, "−" for a cost.
function resourceLine(id, amount, sign) {
+28 -15
View File
@@ -11,18 +11,21 @@ import { dateString } from "../shared/game_clock.js";
// The large collections a delta snapshot may leave out when their version has
// not moved since the last one sent to that peer. The viewer keeps the previous
// copy until it changes. `cityStats` changes with the economy and `visible`
// with the fog, so both are versioned to keep the 10 Hz broadcast small.
// copy until it changes. Each entry pairs the collection with the version key
// that reports its changes; for `tileEthnicity` the version is named
// `ethnicity`. A mismatch here silently ships a whole collection every
// broadcast -- the opening production baseline is static, and shipping its
// several thousand entries on every 10 Hz snapshot was pure waste.
const DELTA_COLLECTIONS = [
"territory",
"regions",
"population",
"gdpBaseline",
"tileEthnicity",
"cityStats",
"visible",
"resourceGraph",
"migrationGraph",
["territory", "territory"],
["regions", "regions"],
["population", "population"],
["productionBaseline", "productionBaseline"],
["tileEthnicity", "ethnicity"],
["cityStats", "cityStats"],
["visible", "visible"],
["resourceGraph", "resourceGraph"],
["migrationGraph", "migrationGraph"],
];
// A wire move order carrying more units than this is split into chunks that
@@ -109,15 +112,25 @@ export class GameServer extends EventEmitter {
});
// 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.
// cold start. The settle runs until commodity prices hold steady (over a
// seven-day window) instead of for a fixed number of days, capped by the
// launcher's `warmupDays` so an unsettled world cannot run away. Off when
// the cap is zero.
const warmupDays = Number(setup.warmupDays || 0);
if (warmupDays > 0) this.state.warmUp(warmupDays);
this.settledDays = warmupDays > 0
? this.state.warmUpToStability({ maxDays: warmupDays })
: 0;
// Lay out every nation's opening forces and reserves only after the settle,
// so the warm-up cannot spend the year of stores the game is meant to open
// with. This is the state the first player joins.
this.state.applyStartingScenario();
this._configured = true;
for (const peerId of this.network.getPeerIds()) {
this._assignCiv(peerId, this.peerNames.get(peerId) || "");
}
this._broadcastState();
this.emit("game_configured");
return this.settledDays;
}
_loadCivs(ids) {
@@ -594,8 +607,8 @@ export class GameServer extends EventEmitter {
return { ...snapshot, delta: false };
}
const state = { ...snapshot, delta: true };
for (const key of DELTA_COLLECTIONS) {
if (previous[key] === versions[key]) delete state[key];
for (const [key, versionKey] of DELTA_COLLECTIONS) {
if (previous[versionKey] === versions[versionKey]) delete state[key];
}
const explored = snapshot.explored || [];
const sent = this._peerExploredSent.get(peerId) || 0;
+11 -5
View File
@@ -128,9 +128,13 @@ export function startServer({
};
const gameServer = new GameServer(network);
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}`);
if (warmupDays > 0) {
log(`settling the world until commodity prices hold steady (up to ${warmupDays} days)...`);
}
const settledDays = gameServer.configureGame({
seed, player_civ: 0, civilisations, testing, mapConfig, warmupDays,
});
if (warmupDays > 0) log(`world settled at day ${settledDays}`);
function broadcastPlayers() {
const players = {};
@@ -268,8 +272,10 @@ 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.
// Settle the world before serving, so the game opens with its economy
// established. The settle stops early once commodity prices hold steady
// over a seven-day window, and this value caps the days it may run.
// `--warmup 0` skips the settle.
warmupDays: Number(argValue("--warmup", process.env.WARMUP || 150)) | 0,
});
}
+35 -6
View File
@@ -52,6 +52,7 @@ import { industryMethods } from "./game_state/industry.js";
import { budgetMethods } from "./game_state/budget.js";
import { moneyMethods } from "./game_state/money.js";
import { taxMethods } from "./game_state/taxes.js";
import { scenarioMethods } from "./game_state/scenario.js";
// Shared empty result for `unitsAt`, so an empty tile does not allocate.
const EMPTY_UNITS = [];
@@ -110,6 +111,9 @@ export class GameState {
// attribute population, GDP and tax to the city that develops the land.
this.tileRegion = new Map();
this._regionByCity = new Map();
// The per-city owned tile lists behind `regionTiles`, rebuilt when the
// territory or region map changes (see territory.js).
this._regionTilesCache = null;
// Tiles carrying a road (pre-generated city network, plus anything the
// players build).
this.roads = new Set();
@@ -207,6 +211,10 @@ export class GameState {
this._approvalCache = new Map();
this._civApprovalCache = new Map();
this._approvalMatrixCache = null;
// Per-civ and per-city ethnic make-ups, rebuilt only when the population,
// ethnicity, territory or migration deltas move (see politics.js).
this._civEthnicMakeupCache = new Map();
this._cityEthnicMakeupCache = new Map();
this._nextUnitId = 1;
this._nextCityId = 1;
@@ -235,9 +243,22 @@ export class GameState {
this._cityByCoords = new Map();
this._cityAdjacent = new Map();
this._territoryByCiv = new Map();
this._gdpPerCapitaCache = new Map();
this._clearTileGdpCache();
// Per-civ population/GDP sums, memoised against the GDP generation,
// population version and territory version (see `_basePlayerAggregates`).
this._baseAggregatesCache = new Map();
// Memoised nation-wide modifier totals, invalidated by _touchModifiers.
this._civModifierCache = new Map();
// Per-city building modifiers and per-nation government/tech modifiers, read
// once per populated tile during the daily economy walk; both invalidated by
// `_modifiersVersion`.
this._cityBuildingModifierCache = new Map();
this._nationalModifierCache = new Map();
// Per-region port production and food bonuses, likewise keyed to the
// modifier version (buildings and ports only change through it).
this._regionSeaBonusCache = new Map();
// Per-nation road/railway tile counts, keyed to the network and territory.
this._transportCountsCache = new Map();
this._modifiersVersion = 0;
// Per-broadcast memo of `getCityEconomy`, active only while the server is
// assembling one snapshot (see beginSnapshotCache). Kept separate from the
@@ -250,6 +271,7 @@ export class GameState {
this._productionBalance = new Map();
this._serializedTerritory = [];
this._serializedExplored = new Map();
this._serializedVisible = new Map();
this._serializedTileEthnicityCache = null;
this._neighbourCache = new Map();
this._visibilityDirty = true;
@@ -446,17 +468,23 @@ export class GameState {
try {
for (let hour = 0; hour < hours; hour++) this.advanceHour();
} finally {
this._tradeGraphCache = null;
this._tradeGraphStamp = null;
this._marketAccessCache = null;
this._resetSettleCaches();
}
}
// The caches a settle leaves stale: the trade graph is kept across the run
// (nothing alters the network), so it is dropped only once the settle ends.
_resetSettleCaches() {
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
// only go stale when a new day begins.
if (this.totalHours % HOURS_PER_DAY === 0) this._gdpPerCapitaCache.clear();
if (this.totalHours % HOURS_PER_DAY === 0) this._clearTileGdpCache();
this._tickMonthly();
this._tickTraining();
this._tickAirUnits();
@@ -520,5 +548,6 @@ Object.assign(
industryMethods,
budgetMethods,
moneyMethods,
taxMethods
taxMethods,
scenarioMethods
);
+2 -2
View File
@@ -214,7 +214,7 @@ export const airMethods = {
const deficit = (this.tileBattleProductionDeficit.get(k) || 0) + perCapita * 0.1;
this.tileBattleProductionDeficit.set(k, deficit);
this.tileBattleRecoveryStart.set(k, this.totalHours + 30 * 24);
this._gdpPerCapitaCache.delete(k);
this._dropTileGdpCache(k);
}
this._damageTileImprovement(city.coords, Math.max(1, Math.round(amount * 0.1)));
this._razeCityBuildings(city, amount);
@@ -258,7 +258,7 @@ export const airMethods = {
}
}
if (razed) {
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
this._touchModifiers();
}
return razed;
+30 -11
View File
@@ -74,19 +74,32 @@ export const budgetMethods = {
// The money one expense category spent on one resource, so the row can be
// expanded to show what it bought. `source` names what consumed it -- a
// building, unit, work or transport type -- and is optional.
_recordBudgetCategoryResource(civ, category, resource, amount, source = null) {
// building, unit, work or transport type -- and is optional. `quantity` is how
// many units of the resource the money bought, so the row can also show the
// price paid per unit.
_recordBudgetCategoryResource(civ, category, resource, amount, source = null, quantity = 0) {
if (civ < 0 || !(amount > 0)) return;
const record = this._budgetRecord(civ);
const byResource = record.resourceByCategory[category]
|| (record.resourceByCategory[category] = {});
let entry = byResource[resource];
if (!entry || typeof entry === "number") {
entry = { total: typeof entry === "number" ? entry : 0, sources: {} };
entry = {
total: typeof entry === "number" ? entry : 0,
quantity: 0,
sources: {},
sourceQuantity: {},
};
byResource[resource] = entry;
}
entry.total += amount;
if (source) entry.sources[source] = (entry.sources[source] || 0) + amount;
if (quantity > 0) entry.quantity = (entry.quantity || 0) + quantity;
if (source) {
entry.sources[source] = (entry.sources[source] || 0) + amount;
if (quantity > 0) {
entry.sourceQuantity[source] = (entry.sourceQuantity[source] || 0) + quantity;
}
}
},
// The money a nation spent buying and earned selling one resource to the
@@ -139,20 +152,26 @@ export const budgetMethods = {
const resourceByCategory = (record && record.resourceByCategory) || {};
for (const category of BUDGET_EXPENSE_CATEGORIES) {
const spent = Math.max(0, -(cash[category.id] || 0));
const cost = Math.max(0, economic[category.id] || 0, spent);
if (spent === 0 && cost === 0) continue;
// Each buy is [resource, money, [[source, money], ...]]: what the
// category bought and which buildings, units or works consumed it.
// Each buy is [resource, money, [[source, money, quantity], ...], quantity]:
// what the category bought, which buildings, units or works consumed it,
// and how many units the money bought (so the panel can show the price).
const buys = Object.entries(resourceByCategory[category.id] || {})
.map(([resource, detail]) => {
if (typeof detail === "number") return [resource, detail, []];
if (typeof detail === "number") return [resource, detail, [], 0];
const sourceQuantity = detail.sourceQuantity || {};
const sources = Object.entries(detail.sources || {})
.filter(([, amount]) => amount > 0)
.sort((a, b) => b[1] - a[1]);
return [resource, detail.total || 0, sources];
.sort((a, b) => b[1] - a[1])
.map(([name, amount]) => [name, amount, sourceQuantity[name] || 0]);
return [resource, detail.total || 0, sources, detail.quantity || 0];
})
.filter(([, amount]) => amount > 0)
.sort((a, b) => b[1] - a[1]);
// A category that only bought materials -- a private improvement upgrade,
// whose cash never passed through the treasury -- still has a cost.
const bought = buys.reduce((sum, buy) => sum + buy[1], 0);
const cost = Math.max(0, economic[category.id] || 0, spent, bought);
if (spent === 0 && cost === 0) continue;
expenses.push({
id: category.id,
label: category.label,
+2 -2
View File
@@ -378,7 +378,7 @@ export const combatMethods = {
const deficit = (this.tileBattleProductionDeficit.get(k) || 0) +
perCapita * BATTLE.productionLossPerDay;
this.tileBattleProductionDeficit.set(k, deficit);
this._gdpPerCapitaCache.delete(k);
this._dropTileGdpCache(k);
scarred = true;
}
if (scarred) {
@@ -408,7 +408,7 @@ export const combatMethods = {
} else {
this.tileBattleProductionDeficit.set(k, remaining);
}
this._gdpPerCapitaCache.delete(k);
this._dropTileGdpCache(k);
}
},
};
+130 -35
View File
@@ -32,6 +32,10 @@ import { tileFoodOutput } from "../resources.js";
import { HOURS_PER_DAY, HOURS_PER_YEAR, DAYS_PER_YEAR } from "./constants.js";
import { effectAt, mergeModifiers } from "./helpers.js";
// The neutral port/food bonuses for a regionless tile, shared so the common
// inland case never allocates.
const NO_SEA_BONUS = { version: -1, portProduction: 0, foodMultiplier: 1 };
export const economyMethods = {
// ------------------------------------------------------ aggregates --
@@ -56,17 +60,47 @@ export const economyMethods = {
// The economy exactly as the tiles describe it, before balancing. Every
// tile's per-capita figure already carries its region's and its nation's
// production modifiers, so the sum needs no further adjustment.
// production modifiers, so the sum needs no further adjustment. The walk
// touches every populated tile a nation holds, and the headline figures are
// read all over the snapshot (diplomacy, the nation bar, the budget), so the
// sum is memoised until the population, the territory or any per-tile GDP
// figure actually moves -- not merely because a unit took a step, which is
// what used to drag this walk onto every 10 Hz broadcast.
_basePlayerAggregates(civ) {
const stamp = `${this._gdpEpoch}:${this._populationVersion}:${this._territoryVersion}`;
const cached = this._baseAggregatesCache.get(civ);
if (cached && cached.stamp === stamp) return cached;
let population = 0;
let baseGdp = 0;
const cells = this._territoryByCiv.get(civ) || [];
for (const coords of cells) {
const pop = this.tilePopulation.get(key(coords.x, coords.y)) || 0;
// An empty tile contributes nothing and its per-capita figure would be
// multiplied by zero anyway, so the walk skips it entirely.
if (pop <= 0) continue;
population += pop;
baseGdp += pop * this.getTileGdpPerCapita(coords);
}
return { population, gdp: baseGdp };
const result = { stamp, population, gdp: baseGdp };
this._baseAggregatesCache.set(civ, result);
return result;
},
// Drops the whole per-tile GDP memo and stamps a new generation. Every caller
// that used to clear `_gdpPerCapitaCache` goes through here so the per-civ
// aggregate memo below (which sums those figures) can tell the figures moved.
_clearTileGdpCache() {
const fresh = new Map();
this._gdpPerCapitaCache = fresh;
this._gdpEpoch = (this._gdpEpoch || 0) + 1;
},
// Forgets one tile's GDP figure and stamps a new generation, so the aggregate
// memo is not left holding a sum that includes the old value.
_dropTileGdpCache(k) {
const cache = this._gdpPerCapitaCache;
cache.delete(k);
this._gdpEpoch = (this._gdpEpoch || 0) + 1;
},
getTileGdpPerCapita(coords) {
@@ -150,7 +184,7 @@ export const economyMethods = {
const perCapita = this.getTileGdpPerCapita(coords);
if (perCapita > 0) this.tileProductionBaseline.set(k, perCapita);
}
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
},
// The flat amount a tile's production per head has gained since January 2000:
@@ -168,22 +202,10 @@ export const economyMethods = {
// The flat production per head a region's ports add to every tile it covers:
// 1000 per level for each sea tile beside the city, summed over its ports.
// Read once per populated tile per day, so the port scan is memoised with the
// food figure below.
_regionPortProduction(region) {
if (!region) return 0;
let bonus = 0;
for (const [index, level] of Object.entries(region.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
bonus += (proto.productionPerSeaTilePerLevel || 0) * level;
}
}
if (bonus === 0) return 0;
let sea = 0;
for (const neighbour of this._neighbours(region.coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") sea += 1;
}
return bonus * sea;
return this._regionSeaBonus(region).portProduction;
},
// The extra food a region's ports bring in, as a multiplier on its tiles'
@@ -191,21 +213,43 @@ export const economyMethods = {
// beside the port city. Returns 1 when the region has no port, so the common
// inland case is free.
_regionFoodMultiplier(region) {
if (!region) return 1;
let bonus = 0;
return this._regionSeaBonus(region).foodMultiplier;
},
// Both port bonuses at once, memoised per region against the modifier version
// (buildings and ports change only through it). The six neighbouring tiles are
// walked once for the pair instead of once per caller.
_regionSeaBonus(region) {
if (!region) return NO_SEA_BONUS;
const fingerprint = cityBuildingsFingerprint(region.buildings);
const cached = this._regionSeaBonusCache.get(region.id);
if (cached && cached.version === this._modifiersVersion && cached.fingerprint === fingerprint) {
return cached;
}
let production = 0;
let food = 0;
for (const [index, level] of Object.entries(region.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.PORT_PRODUCTION) {
bonus += (proto.foodPerSeaTilePerLevel || 0) * level;
production += (proto.productionPerSeaTilePerLevel || 0) * level;
food += (proto.foodPerSeaTilePerLevel || 0) * level;
}
}
if (bonus === 0) return 1;
let sea = 0;
for (const neighbour of this._neighbours(region.coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") sea += 1;
if (production > 0 || food > 0) {
for (const neighbour of this._neighbours(region.coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") sea += 1;
}
}
return 1 + bonus * sea;
const result = {
version: this._modifiersVersion,
fingerprint,
portProduction: production * sea,
foodMultiplier: food > 0 ? 1 + food * sea : 1,
};
this._regionSeaBonusCache.set(region.id, result);
return result;
},
getTileGdp(coords) {
@@ -244,8 +288,14 @@ export const economyMethods = {
return total;
},
// How many road and railway tiles a nation controls.
// How many road and railway tiles a nation controls. The count walks the
// whole network and is read for every nation's upkeep both in the budget
// panel and in the snapshot, so it is memoised until a road, railway or
// territory moves. The set sizes are folded in so a direct edit still counts.
_transportTiles(civ) {
const stamp = `${this._improvementVersion}:${this.roads.size}:${this.railways.size}:${this._territoryVersion}`;
const cached = this._transportCountsCache.get(civ);
if (cached && cached.stamp === stamp) return cached.value;
const counts = { road: 0, railway: 0 };
const sets = [
[this.roads, "road"],
@@ -256,6 +306,7 @@ export const economyMethods = {
if (this.civAt(parseKey(k)) === civ) counts[id] += 1;
}
}
this._transportCountsCache.set(civ, { stamp, value: counts });
return counts;
},
@@ -323,6 +374,8 @@ export const economyMethods = {
for (const coords of this.regionTiles(city)) {
const k = key(coords.x, coords.y);
const pop = this.tilePopulation.get(k) || 0;
// Empty tiles add nothing to the region and need no per-capita figure.
if (pop <= 0) continue;
population += pop;
gdp += pop * this.getTileGdpPerCapita(coords);
}
@@ -572,8 +625,17 @@ export const economyMethods = {
},
// The modifiers one city's own buildings give. Improvements only shape the
// region their city controls, so a tile's per-capita draws on this.
// region their city controls, so a tile's per-capita draws on this. A
// per-capita figure is derived for every populated tile each day, so the same
// handful of cities would otherwise have their buildings re-summed thousands
// of times; the totals are memoised against the modifier version, which every
// building change bumps.
getCityBuildingModifiers(city) {
const fingerprint = cityBuildingsFingerprint(city.buildings);
const cached = this._cityBuildingModifierCache.get(city.id);
if (cached && cached.version === this._modifiersVersion && cached.fingerprint === fingerprint) {
return cached.value;
}
const totals = {};
for (const indexStr of Object.keys(city.buildings)) {
const proto = this.protoBuildings[Number(indexStr)];
@@ -582,6 +644,11 @@ export const economyMethods = {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, level);
}
}
this._cityBuildingModifierCache.set(city.id, {
version: this._modifiersVersion,
fingerprint,
value: totals,
});
return totals;
},
@@ -591,11 +658,15 @@ export const economyMethods = {
},
// Government and research reach every region of the nation; only city
// buildings are regional.
// buildings are regional. Like the per-city totals, this is read once per
// populated tile per day, so it is memoised against the modifier version.
getNationalModifiers(civ) {
const cached = this._nationalModifierCache.get(civ);
if (cached && cached.version === this._modifiersVersion) return cached.value;
const totals = {};
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
this._nationalModifierCache.set(civ, { version: this._modifiersVersion, value: totals });
return totals;
},
@@ -757,7 +828,7 @@ export const economyMethods = {
// from zero. Other technologies keep whatever they had banked.
this._setTechnologyProgress(civ, index, 0);
this._clearFocus(civ);
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
this._emitChanged();
},
@@ -851,6 +922,14 @@ export const economyMethods = {
return total;
},
// A region's population with the same balancing factor `getCityEconomy` uses,
// but without walking every tile's GDP. Callers that only need the headcount
// (food needs, supplier reserves) should use this rather than pay for the
// production figures.
getCityPopulation(city) {
return this.regionPopulation(city) * (this._populationBalance.get(city.civ) || 1);
},
// Pulls `amount` people out of a city's region into a unit, choosing the
// tiles at random in proportion to how many people each holds. Returns how
// many were actually drawn (never more than the region had).
@@ -1029,7 +1108,7 @@ export const economyMethods = {
this._releaseEntryBudget(city.civ, entry);
const level = this.getCityBuildingLevel(city, entry.protoIndex);
city.buildings[entry.protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
this._touchModifiers();
},
@@ -1069,13 +1148,13 @@ export const economyMethods = {
let spentSteel = fromStock.steel * this.getResourcePrice("steel") / v;
let spentTech = fromStock.hightech * this.getResourcePrice("hightech") / v;
let paid = 1;
let gotSteel = 0;
let gotTech = 0;
if (shortfall.steel > 0 || shortfall.hightech > 0) {
// The replacement steel and high-tech come from reachable regions,
// bought by the treasury. Whatever is not available is not healed.
const nodes = this._resourceNodes();
const haul = { steel: 0, hightech: 0 };
let gotSteel = 0;
let gotTech = 0;
if (shortfall.steel > 0) {
const before = payer.total;
gotSteel = this._procureFromNeighbours(
@@ -1096,8 +1175,12 @@ export const economyMethods = {
}
if (spentSteel + spentTech > 0) {
this._recordBudgetCash(city.civ, "units", -(spentSteel + spentTech));
this._recordBudgetCategoryResource(city.civ, "units", "steel", spentSteel);
this._recordBudgetCategoryResource(city.civ, "units", "hightech", spentTech);
this._recordBudgetCategoryResource(
city.civ, "units", "steel", spentSteel, proto.name, fromStock.steel + gotSteel
);
this._recordBudgetCategoryResource(
city.civ, "units", "hightech", spentTech, proto.name, fromStock.hightech + gotTech
);
this._recordResourceSpend(city.civ, "upkeep", spentSteel + spentTech, {
steel: spentSteel,
hightech: spentTech,
@@ -1112,3 +1195,15 @@ export const economyMethods = {
}
},
};
// A compact fingerprint of a city's building levels. The modifier and port
// memos key on it so a direct edit -- a test raising a museum, a save being
// loaded -- is noticed even when the modifier-version counter was not bumped.
// Only the handful of actually-set buildings are walked, so it stays cheap.
function cityBuildingsFingerprint(buildings) {
let fingerprint = "";
for (const index in buildings) {
fingerprint += `${index}=${buildings[index]};`;
}
return fingerprint;
}
+37 -12
View File
@@ -27,7 +27,7 @@ import {
import { HOURS_PER_DAY } from "./constants.js";
function emptyBuildingAgent() {
return { level: 0, cash: 0, salesToday: 0, sales: [], upgrading: null };
return { level: 0, cash: 0, debt: 0, salesToday: 0, sales: [], upgrading: null };
}
export const industryMethods = {
@@ -115,28 +115,40 @@ export const industryMethods = {
// A payer that draws on one production building's own cash.
_buildingPayer(k) {
const agent = this._ensureBuildingAgent(k);
const payer = { total: 0 };
payer.charge = (cost) => {
if (!(cost > 0)) return;
// An agent never mints money: a bill it cannot cover leaves it overdrawn,
// to be paid off from the next sales.
agent.cash = (agent.cash || 0) - cost;
this._chargeBuildingAccount(k, cost);
payer.total += cost;
};
return payer;
},
// Charges one production building, spending its cash and borrowing whatever
// is missing from the national central bank at the bank's rate. Like a region,
// an agent never goes into the red: it takes on a loan instead, which the
// daily tick compounds and repays from later sales.
_chargeBuildingAccount(k, amount) {
if (!(amount > 0)) return;
const agent = this._ensureBuildingAgent(k);
const cash = agent.cash || 0;
if (cash >= amount) {
agent.cash = cash - amount;
return;
}
agent.cash = 0;
agent.debt = (agent.debt || 0) + (amount - cash);
},
// Charges one production building for something the grid delivered to it,
// from its own cash. Like `_buildingPayer`, it may go overdrawn rather than
// fail, so the money a supplier receives is real.
// from its own cash, borrowing from the central bank where the cash falls
// short, so the money a supplier receives is real.
_chargeBuildingCash(k, code, cost) {
if (!(cost > 0)) return;
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) return;
const agent = this._ensureBuildingAgent(k);
const value = cost * this.currencyValueOf(code) / this.currencyValue(owner);
agent.cash = (agent.cash || 0) - value;
this._chargeBuildingAccount(k, value);
},
// Pays a grid's plants for `kwh` of power worth `money`, split by how much
@@ -168,6 +180,17 @@ export const industryMethods = {
return total;
},
// What the nation's production buildings owe the central bank in total.
getPrivateBuildingDebt(civ) {
if (!this.buildingAgents) return 0;
let total = 0;
for (const [k, agent] of this.buildingAgents) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
total += agent.debt || 0;
}
return total;
},
// A nation's production agents as plain objects, for the Economy tab's
// separate private-sector row and the tile panel.
getProductionAgents(civ) {
@@ -188,6 +211,7 @@ export const industryMethods = {
resource: proto.resource,
level: agent.level,
cash: agent.cash || 0,
debt: agent.debt || 0,
outputPerDay: resourceBuildingOutputAt(proto, agent.level),
upgrading: !!job,
upgradeDaysLeft: job
@@ -326,17 +350,18 @@ export const industryMethods = {
return false;
}
const money = resourceBuildingUpgradeMoneyCost(proto, level);
const budget = Math.max(0, agent.cash || 0);
agent.cash = 0;
// A private work: the agent keeps its cash and the site draws on it (then a
// central-bank loan) as it buys, so an upgrade is never a public expense.
const site = this._openConstructionSite({
civ: owner,
coords,
proto,
kind: "upgrade",
targetLevel: level + 1,
money: budget,
money,
buildCost: money,
level,
isPrivate: true,
});
this._kickoffSite(site);
this._tileImprovementVersion += 1;
+18
View File
@@ -604,6 +604,24 @@ export const moneyMethods = {
this._repatriateRegion(city, basket);
this._repayRegionDebt(city, basket);
}
// Production buildings borrow from the same bank: their loans compound and
// any cash they hold pays the debt down, so one never has to go into the red.
for (const [k, agent] of this.buildingAgents || []) {
const owner = this.tileImprovementOwner.get(k);
if (owner === undefined || owner < 0) continue;
const debt = agent.debt || 0;
if (debt > 0) {
const rate = this.getCentralBankRate(owner) / DAYS_PER_YEAR;
agent.debt = debt * (1 + rate);
}
const owing = agent.debt || 0;
const cash = agent.cash || 0;
if (owing > 0 && cash > 0) {
const repay = Math.min(cash, owing);
agent.cash = cash - repay;
agent.debt = owing - repay;
}
}
for (let civ = 0; civ < this.civilisations.length; civ++) {
// A treasury in the red is an overdraft that compounds too.
const budget = this.getBudget(civ);
+5 -5
View File
@@ -93,7 +93,7 @@ export const orderMethods = {
this.getCityBuildingLevel(city, protoIndex) +
this._pendingBuildingLevels(cityId, protoIndex);
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
this._touchModifiers();
this._emitChanged();
return true;
@@ -107,7 +107,7 @@ export const orderMethods = {
// A free improvement is raised at once.
if (cost <= 0) {
city.buildings[protoIndex] = level + 1;
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
this._touchModifiers();
this._emitChanged();
return true;
@@ -226,7 +226,7 @@ export const orderMethods = {
// the level are lost.
if (level <= 1) delete city.buildings[protoIndex];
else city.buildings[protoIndex] = level - 1;
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
this._touchModifiers();
this._emitChanged();
return true;
@@ -240,7 +240,7 @@ export const orderMethods = {
GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0);
if (cost > 0) this._spendBudget(civ, cost, "campaign");
this.government.set(civ, index);
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
this._touchModifiers();
this._emitChanged();
return true;
@@ -302,7 +302,7 @@ export const orderMethods = {
this.tilePopulation.set(k, population);
this._populationVersion += 1;
this.tileProductionPenalty.set(k, penalty);
this._gdpPerCapitaCache.delete(k);
this._dropTileGdpCache(k);
// The people driven off the tile are civilians lost to its owner in the war
// against the pillaging civilisation.
if (owner >= 0) this._recordCasualties(owner, units[0].civ, "civilians", drivenOff);
+21 -5
View File
@@ -212,9 +212,16 @@ export const politicsMethods = {
// balancing factor the economy uses so the counts match the headline
// population the rest of the UI shows.
getCivEthnicMakeup(civ) {
const stamp = `${this._ethnicityVersion}:${this._populationVersion}:${this._territoryVersion}`;
const cached = this._civEthnicMakeupCache.get(civ);
if (cached && cached.stamp === stamp && cached.deltas === this._migrationDeltas) {
return cached.value;
}
const scale = this._populationBalance.get(civ) || 1;
const counts = this._ethnicCountsOver(this._territoryByCiv.get(civ) || []);
return this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
const value = this._makeupList(counts, this._migrationDeltas.civ.get(civ), scale);
this._civEthnicMakeupCache.set(civ, { stamp, deltas: this._migrationDeltas, value });
return value;
},
// One ethnicity's share of a civilisation's whole population, 0 when it has
@@ -230,11 +237,20 @@ export const politicsMethods = {
return total > 0 ? target / total : 0;
},
// The ethnic makeup of one city's region.
// The ethnic makeup of one city's region. Like the national figure it is
// rebuilt only when the population, ethnicity or territory moves, so the
// region walk is not repeated on every snapshot the server broadcasts.
getCityEthnicMakeup(city) {
const stamp = `${this._ethnicityVersion}:${this._populationVersion}:${this._territoryVersion}:${this._regionVersion}`;
const cached = this._cityEthnicMakeupCache.get(city.id);
if (cached && cached.stamp === stamp && cached.deltas === this._migrationDeltas) {
return cached.value;
}
const scale = this._populationBalance.get(city.civ) || 1;
const counts = this._ethnicCountsOver(this.regionTiles(city));
return this._makeupList(counts, this._migrationDeltas.city.get(city.id), scale);
const value = this._makeupList(counts, this._migrationDeltas.city.get(city.id), scale);
this._cityEthnicMakeupCache.set(city.id, { stamp, deltas: this._migrationDeltas, value });
return value;
},
_makeupList(counts, trend, scale = 1) {
@@ -849,7 +865,7 @@ export const politicsMethods = {
this._setTileEthnicFractions(parseKey(entry.k), shares);
}
this._populationVersion += 1;
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
// Keep each city's headline population in step with its city tile.
for (const city of [fromCity, toCity]) {
city.population = Math.round(
@@ -923,7 +939,7 @@ export const politicsMethods = {
this._redistributePopulation(cities, population, targetByCity);
}
}
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
},
// Moves people from the regions holding more than their target to those
+160 -39
View File
@@ -311,7 +311,7 @@ export const resourceMethods = {
for (const claim of claims) {
this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
}
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
},
// The greedy heart of the seeding: each pass covers every resource the nation
@@ -375,7 +375,7 @@ export const resourceMethods = {
// have added their draw, and the plants themselves add fuel and upkeep.
// The GDP figure the ledger reads must reflect those converters, so the
// per-tile cache is dropped before it is consulted.
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
const energy = this._resourceSeedEnergy(civ, components, foodEnergy);
for (const entry of energy) {
const target = entry.deficit * SEED_ENERGY_MARGIN;
@@ -543,6 +543,46 @@ export const resourceMethods = {
}
},
// Settles the world until its commodity prices are steady: it keeps advancing
// whole days until, over the trailing `window` days, no commodity has moved
// more than `tolerance` from where it stood at the start of that window. A
// quiet market is reached in far fewer days than a fixed settle would wait, so
// the game can open with its economy already established without the long
// fixed run. `maxDays` caps the settle so an unsettled world cannot hang the
// server; a price series too short to judge simply keeps the economy running.
// Returns the number of days actually settled.
warmUpToStability({ window = 7, tolerance = 0.1, maxDays = 150 } = {}) {
const cap = Math.max(0, Math.floor(maxDays));
if (cap <= 0) return 0;
let day = 0;
try {
while (day < cap) {
for (let hour = 0; hour < HOURS_PER_DAY; hour++) this.advanceHour();
day += 1;
if (day >= window && this.pricesAreSteady(window, tolerance)) break;
}
} finally {
this._resetSettleCaches();
}
return day;
},
// Whether every commodity's world price has held within `tolerance` of its
// price `window` days ago. A series shorter than the window cannot be judged,
// so it reads as not yet steady.
pricesAreSteady(window = 7, tolerance = 0.1) {
if (!this.resourceDailyPrices) return true;
for (const id of RESOURCE_IDS) {
const series = this.resourceDailyPrices.get(id) || [];
if (series.length <= window) return false;
const before = series[series.length - 1 - window];
const now = series[series.length - 1];
if (!(before > 0)) continue;
if (Math.abs(now / before - 1) > tolerance) return false;
}
return true;
},
// A commodity's price index and its month-on-month and year-on-year inflation,
// from the monthly series. The index is 100 at the opening price in January
// 2000. Monthly compares with last month's opening, yearly with the same month
@@ -1036,13 +1076,50 @@ export const resourceMethods = {
// The same daily wear as one materials demand, for the summary panels and the
// market-value forecast.
getCivUpkeepResources(civ) {
const categories = this.getCivUpkeepBreakdown(civ);
return this.getCivUpkeepTotals(civ);
},
// The nation's daily wear summed without building the per-category labels,
// part lists and merged sources the budget panel needs. The summary and the
// HUD upkeep read this several times per broadcast, so they skip that
// bookkeeping rather than allocate a breakdown just to add four numbers.
getCivUpkeepTotals(civ) {
const total = { steel: 0, energy: 0, hightech: 0, food: 0 };
for (const costs of Object.values(categories)) {
total.steel += costs.steel;
total.energy += costs.energy;
total.hightech += costs.hightech;
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const [index, level] of Object.entries(city.buildings || {})) {
if (level <= 0) continue;
const proto = this.protoBuildings[Number(index)];
if (!proto) continue;
const costs = this.buildingUpkeepResources(proto, level);
total.steel += costs.steel || 0;
total.energy += costs.energy || 0;
total.hightech += costs.hightech || 0;
}
}
const battle = this._battleUnitIds();
for (const unit of this.units) {
if (unit.civ !== civ) continue;
const proto = this.unitProto(unit);
if (!proto) continue;
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
const multiplier = this._statusUpkeepMultiplier(statuses);
const costs = this.unitUpkeepResources(proto);
total.steel += (costs.steel || 0) * multiplier;
total.hightech += (costs.hightech || 0) * multiplier;
}
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!proto) continue;
const costs = protoUpkeep(proto, proto.buildCost || 0);
total.steel += costs.steel || 0;
total.energy += (costs.energy || 0) + (proto.fuelEnergyPerDay || 0);
total.hightech += costs.hightech || 0;
}
const counts = this._transportTiles(civ);
total.steel += counts.road * RESOURCE_RULES.transportUpkeepSteel.road +
counts.railway * RESOURCE_RULES.transportUpkeepSteel.railway;
return total;
},
@@ -1086,41 +1163,69 @@ export const resourceMethods = {
// demand is cut by what it covered, so a well funded work spares the treasury
// until it runs dry. The spent money is paid into the nation's regions, since
// it is the private sector the state would otherwise have paid.
//
// A private producer -- a mine, a mill or a plant -- is the exception: it is
// its own business, so it pays the whole day's wear from the cash its output
// earned and borrows from the central bank when that cash falls short. The
// state never subsidises a producer, so its wear never reaches the treasury
// bill.
_applyWorksMaintenance(civ, categories) {
if (!this.improvementCash || !categories) return;
if (!categories) return;
const steelUnit = this._marketUnitCost("steel");
const techUnit = this._marketUnitCost("hightech");
let covered = 0;
// Spend one work's reserve against its own steel/high-tech wear, trimming
// the matching budget category by whatever the reserve could cover.
const apply = (k, steel, hightech, category) => {
const reserve = this.improvementCash.get(k) || 0;
if (!(reserve > 0)) return;
// the matching budget category and its per-label source by whatever it
// covered. `full` bills the owner for the whole day whatever its reserve
// (a private producer, which pays from its cash and borrows what is short).
const apply = (reserve, spend, steel, hightech, label, category, full = false) => {
const value = steel * steelUnit + hightech * techUnit;
if (!(value > 0)) return;
const fraction = Math.min(1, reserve / value);
const fraction = full ? 1 : (reserve > 0 ? Math.min(1, reserve / value) : 0);
if (!(fraction > 0)) return;
const spent = value * fraction;
covered += spent;
const after = reserve - spent;
if (after > 0) this.improvementCash.set(k, after);
else this.improvementCash.delete(k);
spend(spent);
if (category) {
category.steel = Math.max(0, (category.steel || 0) - steel * fraction);
category.hightech = Math.max(0, (category.hightech || 0) - hightech * fraction);
const source = (category.sources || []).find((entry) => entry.label === label);
if (source) {
source.steel = Math.max(0, (source.steel || 0) - steel * fraction);
source.hightech = Math.max(0, (source.hightech || 0) - hightech * fraction);
}
}
};
// Pay a reserve from the tile's own improvement cash, which never overdraws.
const spendReserve = (k) => (spent) => {
const after = (this.improvementCash.get(k) || 0) - spent;
if (after > 0) this.improvementCash.set(k, after);
else this.improvementCash.delete(k);
};
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!proto) continue;
const costs = protoUpkeep(proto, proto.buildCost || 0);
apply(k, costs.steel || 0, costs.hightech || 0, categories.works);
const steel = costs.steel || 0;
const hightech = costs.hightech || 0;
if (this._isProductionAgent(proto)) {
apply(0, (spent) => this._chargeBuildingAccount(k, spent),
steel, hightech, proto.name, categories.works, true);
} else {
apply(this.improvementCash.get(k) || 0, spendReserve(k),
steel, hightech, proto.name, categories.works);
}
}
for (const [tiles, id] of [[this.roads, "road"], [this.railways, "railway"]]) {
for (const [tiles, id, label] of [
[this.roads, "road", "Roads"],
[this.railways, "railway", "Railways"],
]) {
const steel = RESOURCE_RULES.transportUpkeepSteel[id] || 0;
for (const k of tiles) {
if (this.civAt(parseKey(k)) !== civ) continue;
apply(k, steel, 0, categories.transport);
apply(this.improvementCash.get(k) || 0, spendReserve(k),
steel, 0, label, categories.transport);
}
}
// The reserves paid the private sector the state would otherwise have paid.
@@ -1209,7 +1314,10 @@ export const resourceMethods = {
hightech: spentTech,
});
// Split the cash across the categories that wore the material, then across
// the buildings, units, works and transport that consumed it.
// the buildings, units, works and transport that consumed it. The worn
// quantity travels with the cash, so each row can show the price it paid.
const consumedSteel = drawnSteel + gotSteel;
const consumedTech = drawnTech + gotTech;
for (const id of ids) {
const demand = categories[id] || {};
const steelCash = totalSteel > 0 ? spentSteel * ((demand.steel || 0) / totalSteel) : 0;
@@ -1218,10 +1326,12 @@ export const resourceMethods = {
if (cash > 0) this._recordBudgetCash(civ, id, -cash);
const sources = (categories[id] && categories[id].sources) || [];
if (steelCash > 0) {
this._recordResourceBySource(civ, id, "steel", steelCash, sources, "steel");
const qty = totalSteel > 0 ? consumedSteel * ((demand.steel || 0) / totalSteel) : 0;
this._recordResourceBySource(civ, id, "steel", steelCash, sources, "steel", qty);
}
if (techCash > 0) {
this._recordResourceBySource(civ, id, "hightech", techCash, sources, "hightech");
const qty = totalTech > 0 ? consumedTech * ((demand.hightech || 0) / totalTech) : 0;
this._recordResourceBySource(civ, id, "hightech", techCash, sources, "hightech", qty);
}
}
}
@@ -1235,23 +1345,28 @@ export const resourceMethods = {
// Files a category's market purchase of one resource under the sources that
// consumed it -- the building, unit, work or transport type -- split in
// proportion to how much of that resource each source wore out.
_recordResourceBySource(civ, category, resource, cash, sources, field) {
// proportion to how much of that resource each source wore out. `quantity` is
// how much of the resource the cash bought, split the same way, so the budget
// row can show the average price paid per unit.
_recordResourceBySource(civ, category, resource, cash, sources, field, quantity = 0) {
let total = 0;
for (const source of sources) total += source[field] || 0;
if (!(total > 0)) {
this._recordBudgetCategoryResource(civ, category, resource, cash);
this._recordBudgetCategoryResource(civ, category, resource, cash, null, quantity);
return;
}
let remaining = cash;
let remainingQty = quantity;
for (let i = 0; i < sources.length; i++) {
const source = sources[i];
const amount = i === sources.length - 1
? remaining
: cash * ((source[field] || 0) / total);
const last = i === sources.length - 1;
const share = (source[field] || 0) / total;
const amount = last ? remaining : cash * share;
const qty = last ? remainingQty : quantity * share;
remaining -= amount;
remainingQty -= qty;
if (amount > 0) {
this._recordBudgetCategoryResource(civ, category, resource, amount, source.label);
this._recordBudgetCategoryResource(civ, category, resource, amount, source.label, qty);
}
}
},
@@ -2329,12 +2444,16 @@ export const resourceMethods = {
// 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.
// when a road, railway or territory is added or removed or redrawn with the
// size of the storage-node set, so even a direct edit picks a fresh stamp and
// rebuilds lazily; it is all O(1) reads. It deliberately leaves out the tile
// improvements and their version: a road, railway or storage work moves the
// edges or the nodes, but rebuilding a mine, mill or plant in place -- which
// bumps `_tileImprovementVersion` many times a day -- cannot change a single
// transport route, and treating it as one threw every city's cached walk away.
_tradeGraph(city, nodes, allowSea) {
const stamp = `${this._improvementVersion}:${this.roads.size}:${this.railways.size}` +
`:${this._tileImprovementVersion}:${this.tileImprovements.size}:${this._territoryVersion}`;
`:${this._territoryVersion}:${this.resourceStock ? this.resourceStock.size : 0}`;
if (!this._tradeGraphCache || this._tradeGraphStamp !== stamp) {
this._tradeGraphCache = new Map();
this._tradeGraphStamp = stamp;
@@ -2467,7 +2586,7 @@ export const resourceMethods = {
_nodeCityPopulation(city) {
const cache = this._nodePopulationCache;
if (cache && cache.has(city.id)) return cache.get(city.id);
const population = this.getCityEconomy(city).population;
const population = this.getCityPopulation(city);
if (cache) cache.set(city.id, population);
return population;
},
@@ -2542,7 +2661,7 @@ export const resourceMethods = {
const cost = this.constructionResourceCost(proto, level - 1, buildCost);
if (!this._payConstructionResources(city.civ, city.coords, cost)) continue;
city.buildings[job.index] = level;
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
this._touchModifiers();
queue.shift();
if (queue.length === 0) this.cityRepairQueue.delete(cityId);
@@ -2701,17 +2820,19 @@ export const resourceMethods = {
// The city's own food is bought from the region that stored it.
const takeValue = take * this.getResourcePrice("food") / this.currencyValue(unit.civ);
this._payRegionForMaterial(city, "food", take, payer);
this._recordProvisionSpend(unit.civ, takeValue, procuredSpent);
const proto = this.unitProto(unit);
this._recordProvisionSpend(unit.civ, takeValue, procuredSpent, tonnes, proto ? proto.name : null);
return true;
},
// Books the food a unit ate, whether it came from a region's store or from a
// foreign one.
_recordProvisionSpend(civ, takeValue, procuredSpent) {
// foreign one. `quantity` is the ration in tonnes and `source` the unit type,
// so the budget row can show what was eaten and the average price paid.
_recordProvisionSpend(civ, takeValue, procuredSpent, quantity = 0, source = null) {
const spent = takeValue + procuredSpent;
if (!(spent > 0)) return;
this._recordBudgetCash(civ, "units", -spent);
this._recordBudgetCategoryResource(civ, "units", "food", spent);
this._recordBudgetCategoryResource(civ, "units", "food", spent, source, quantity);
this._recordResourceSpend(civ, "upkeep", spent, "food");
},
+104
View File
@@ -0,0 +1,104 @@
// The opening order of battle and the reserves every civilisation starts a game
// with. The scenario is applied by the server once the world has been settled,
// not during world generation, so the economy's warm-up cannot spend the
// opening stores: a player joining the server sees exactly these figures.
import { STORABLE_RESOURCE_IDS } from "../data.js";
// The forces a nation is founded with. Infantry and artillery stand in every
// city, the launches and battleships of a city's port are launched onto its
// adjacent water, and the capital fields the air force.
export const STARTING_FORCES = {
infantryPerCity: 15,
artilleryPerCity: 5,
launchesPerPort: 5,
battleshipsPerPort: 2,
fightersPerCapital: 5,
bombersPerCapital: 5,
};
// Every region opens holding a year of what it consumes. Energy is a flow with
// no store, so only the four storable goods are stocked.
export const STARTING_RESERVE_DAYS = 365;
export const scenarioMethods = {
// Applies the whole opening scenario to a settled world. Any units the
// generated world seeded are cleared first, so the order of battle is exactly
// the one described here and never picks up a stray starting unit.
applyStartingScenario() {
this._clearStartingForces();
this._seedStartingBuildings();
this._seedStartingUnits();
this._seedStartingReserves();
this._emitChanged();
},
_clearStartingForces() {
for (const unit of this.units.slice()) this._destroyUnit(unit);
},
// Every city raises a barracks; the capital also an airport.
_seedStartingBuildings() {
const barracks = this.buildingIndexById("barracks");
const airport = this.buildingIndexById("airport");
for (const city of this.cities) {
if (barracks >= 0) {
city.buildings[barracks] = Math.max(1, city.buildings[barracks] || 0);
}
if (airport >= 0 && city.isCapital) {
city.buildings[airport] = Math.max(1, city.buildings[airport] || 0);
}
}
this._clearTileGdpCache();
this._touchModifiers();
},
_seedStartingUnits() {
const protoById = (id) => this.protoUnits.find((unit) => unit.id === id);
const infantry = protoById("modern_infantry");
const artillery = protoById("artillery");
const launch = protoById("launch");
const battleship = protoById("battleship");
const fighter = protoById("jet_fighter");
const bomber = protoById("bomber");
for (const city of this.cities) {
for (let i = 0; i < STARTING_FORCES.infantryPerCity; i++) {
this._spawnUnit(city.coords, city.civ, infantry, city.id);
}
for (let i = 0; i < STARTING_FORCES.artilleryPerCity; i++) {
this._spawnUnit(city.coords, city.civ, artillery, city.id);
}
if (!this.hasCityBuilding(city, "port")) continue;
for (let i = 0; i < STARTING_FORCES.launchesPerPort; i++) {
this._spawnUnit(this._spawnCoordsFor(city, launch), city.civ, launch, city.id);
}
for (let i = 0; i < STARTING_FORCES.battleshipsPerPort; i++) {
this._spawnUnit(this._spawnCoordsFor(city, battleship), city.civ, battleship, city.id);
}
}
for (const city of this.cities) {
if (!city.isCapital) continue;
for (let i = 0; i < STARTING_FORCES.fightersPerCapital; i++) {
this._spawnUnit(city.coords, city.civ, fighter, city.id);
}
for (let i = 0; i < STARTING_FORCES.bombersPerCapital; i++) {
this._spawnUnit(city.coords, city.civ, bomber, city.id);
}
}
},
// Fills every region's store with a year of its daily consumption, so a fresh
// nation can fight and build for a long while before its industry and its
// trade routes must carry it.
_seedStartingReserves() {
for (const city of this.cities) {
const consumption = this.getRegionResourceInfo(city).consumption;
const stock = this.getCityResourceStock(city);
for (const id of STORABLE_RESOURCE_IDS) {
stock[id] = (consumption[id] || 0) * STARTING_RESERVE_DAYS;
}
}
},
};
+7 -2
View File
@@ -5,7 +5,6 @@ import { key, parseKey } from "../hex.js";
import { OPINION } from "../data/politics.js";
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
import { RESOURCE_IDS } from "../data/resources.js";
import { serializeSet } from "./helpers.js";
import { HOURS_PER_DAY } from "./constants.js";
export const serializationMethods = {
@@ -140,7 +139,7 @@ export const serializationMethods = {
result.viewer = viewerCiv;
result.viewerStats = stats || this.viewerStats(viewerCiv);
result.explored = this._cachedExplored(viewerCiv);
result.visible = serializeSet(this.visible.get(viewerCiv) || new Set());
result.visible = this._cachedVisible(viewerCiv);
// What a city is producing is its owner's business: every other viewer sees
// an empty queue, so neither the map bar nor the city panel can reveal
// whether a foreign city is busy.
@@ -166,6 +165,7 @@ export const serializationMethods = {
taxTake: { sales: 0, export: 0, import: 0 },
commodityInflation: 0,
privateBuildingCash: 0,
privateBuildingDebt: 0,
productionBuildings: [],
};
}
@@ -210,6 +210,7 @@ export const serializationMethods = {
// Production buildings are private-sector agents too, but a separate
// class from the regions: they keep their own cash, level and upgrade.
privateBuildingCash: this.getPrivateBuildingCash(viewerCiv),
privateBuildingDebt: this.getPrivateBuildingDebt(viewerCiv),
productionBuildings: this.getProductionAgents(viewerCiv),
// Where every unit of the viewer's currency is stored: its treasury, its
// regions, and foreign regions and central banks that hold it.
@@ -234,6 +235,9 @@ export const serializationMethods = {
return this.budgetMonthReports(civ).map((report) => ({
monthIndex: report.monthIndex,
isCurrent: report.isCurrent,
// The human-readable month, so the pager and the subtitle can name the
// month without rebuilding the calendar on the client.
label: report.label,
income: report.income,
taxes: report.taxes,
sales: report.sales,
@@ -519,6 +523,7 @@ export const serializationMethods = {
id: site.id,
kind: site.kind,
targetLevel: site.targetLevel,
private: !!site.private,
phase: site.phase,
stalled: site.stalled,
budget: site.budget,
+42 -11
View File
@@ -118,10 +118,14 @@ export const siteMethods = {
// Opens a construction site on a tile. `money` is the forecast budget moved
// out of the coffers (or a private agent's cash) into the node; the caller has
// already reserved it. `proto` is the improvement being raised and `level` the
// production level a fresh build starts from (0) or the level an upgrade
// targets minus one.
_openConstructionSite({ civ, coords, proto, kind = "build", targetLevel = 0, money, buildCost, level = 0 }) {
// already reserved it. A `private` site belongs to a production agent: it keeps
// `money` only as the forecast the panel shows, and every purchase is charged
// to that agent's account (cash, then a central-bank loan) instead of the
// treasury. `proto` is the improvement being raised and `level` the production
// level a fresh build starts from (0) or the level an upgrade targets minus one.
_openConstructionSite({
civ, coords, proto, kind = "build", targetLevel = 0, money, buildCost, level = 0, isPrivate = false,
}) {
const k = key(coords.x, coords.y);
const materials = kind === "upgrade"
? this.constructionResourceCost(proto, level, buildCost)
@@ -138,6 +142,7 @@ export const siteMethods = {
civ,
kind,
targetLevel,
private: !!isPrivate,
phase: "materials",
budget: Math.max(0, money || 0),
prices: this._lockedUnitPrices(),
@@ -251,7 +256,8 @@ export const siteMethods = {
// 8%, ...), so a stubborn price is met within a few days. A supplier still
// above the raised cap simply stalls it once more; the count resets as soon as
// it buys. When no supplier can reach the site at any price the rise cannot
// help, so it is left alone.
// help, so it is left alone. A private site raises its own agent's account
// instead of the treasury: the money is borrowed, not subsidised.
_raiseSiteBudgetIfStalled(site) {
if (!site.stalled || site.phase !== "materials") return;
if (this._siteUnreachableMaterials(site).length > 0) return;
@@ -260,7 +266,7 @@ export const siteMethods = {
for (const id of RESOURCE_IDS) site.prices[id] *= factor;
site.budgetRaises = raises + 1;
const add = this._siteTopUp(site, site.prices);
this._reserveSiteBudget(site.civ, add);
if (!site.private) this._reserveSiteBudget(site.civ, add);
site.budget += add;
this._gatherSiteMaterials(site);
this._emitChanged();
@@ -276,6 +282,7 @@ export const siteMethods = {
const city = this._cityById.get(site.cityId);
if (!city) return 0;
const payer = this._sitePayer(site);
const paidBefore = payer.total;
let bought = 0;
// The hub city already holds some of the material; the state buys it from
// the region at the market value before reaching further afield.
@@ -299,6 +306,15 @@ export const siteMethods = {
});
bought += temp[id] || 0;
}
// File the purchase under tile construction with its quantity, so the
// budget panel can show how many units were bought and at what price. A
// private work is not a public expense, so it is left out of the budget.
if (bought > 0 && !site.private) {
const proto = tileImprovementById(site.id) || transportImprovement(site.id);
this._recordBudgetCategoryResource(
site.civ, "tilebuild", id, payer.total - paidBefore, proto ? proto.name : site.id, bought
);
}
return bought;
},
@@ -316,9 +332,20 @@ export const siteMethods = {
},
// A payer that empties the site's own account first, then draws on the
// nation's treasury, so an overrun is public money.
// nation's treasury, so an overrun is public money. A private site instead
// draws on its production agent: that agent's cash first, then a central-bank
// loan, so a producer is never subsidised by the state.
_sitePayer(site) {
const payer = { total: 0, overflow: 0 };
if (site.private) {
const k = key(site.coords.x, site.coords.y);
payer.charge = (cost) => {
if (!(cost > 0)) return;
this._chargeBuildingAccount(k, cost);
payer.total += cost;
};
return payer;
}
payer.charge = (cost) => {
if (!(cost > 0)) return;
const fromBudget = Math.min(site.budget, cost);
@@ -413,8 +440,10 @@ export const siteMethods = {
const agent = this._ensureBuildingAgent(k);
agent.level = site.targetLevel;
agent.upgrading = null;
agent.cash = (agent.cash || 0) + site.budget;
this._gdpPerCapitaCache.clear();
// A private upgrade borrowed as it spent, so there is no unspent public
// budget to hand back.
if (!site.private) agent.cash = (agent.cash || 0) + site.budget;
this._clearTileGdpCache();
this._touchModifiers();
this._tileImprovementVersion += 1;
} else if (transportImprovement(site.id)) {
@@ -432,7 +461,9 @@ export const siteMethods = {
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[site.id] || 1000);
if (this._isProductionAgent(proto)) {
const agent = this._ensureBuildingAgent(k);
agent.cash = (agent.cash || 0) + site.budget;
// A public work hands its unspent budget to the new private owner as
// working capital; a private one already borrowed for itself.
if (!site.private) agent.cash = (agent.cash || 0) + site.budget;
} else if (site.budget > 0) {
this.improvementCash.set(k, (this.improvementCash.get(k) || 0) + site.budget);
}
@@ -440,7 +471,7 @@ export const siteMethods = {
this._visibilityDirty = true;
}
this.constructionSites.delete(k);
this._gdpPerCapitaCache.delete(k);
this._dropTileGdpCache(k);
this._emitChanged();
},
+22 -4
View File
@@ -7,6 +7,9 @@ import { POPULARITY } from "../data/politics.js";
import { tileImprovementById } from "../data/improvements.js";
import { serializeCells } from "./helpers.js";
// Shared empty result for a city with no region, so a lookup never allocates.
const EMPTY_TILES = [];
export const territoryMethods = {
civAt(coords) {
const k = key(coords.x, coords.y);
@@ -35,7 +38,7 @@ export const territoryMethods = {
_recomputeTerritory() {
this.territory = new Map();
this._gdpPerCapitaCache = new Map();
this._clearTileGdpCache();
const ownerByCell = new Map();
let frontier = [];
for (const city of this.cities) {
@@ -123,9 +126,24 @@ export const territoryMethods = {
// The land tiles a city's region covers that are still owned by the city's
// civilisation. A freshly captured city only taxes the land that came with it.
// The economy walks these regions many times a day (every city's GDP, energy
// draw and food need), and the filter behind it touches every tile, so the
// per-city lists are rebuilt only when the territory or the region map moves.
// Callers treat the arrays as read-only.
regionTiles(city) {
const tiles = this._regionByCity.get(city.id) || [];
return tiles.filter((coords) => this.civAt(coords) === city.civ);
const stamp = `${this._territoryVersion}:${this._regionVersion}`;
let cache = this._regionTilesCache;
if (!cache || cache.stamp !== stamp) {
const byCity = new Map();
for (const [cityId, tiles] of this._regionByCity) {
const owner = this._cityById.get(cityId);
if (!owner) continue;
byCity.set(cityId, tiles.filter((coords) => this.civAt(coords) === owner.civ));
}
cache = { stamp, byCity };
this._regionTilesCache = cache;
}
return cache.byCity.get(city.id) || EMPTY_TILES;
},
_indexTerritory() {
@@ -136,7 +154,7 @@ export const territoryMethods = {
this._territoryByCiv.get(owner).push(parseKey(k));
}
this._serializedTerritory = serializeCells(this.territory);
this._gdpPerCapitaCache = new Map();
this._clearTileGdpCache();
// Which tiles a nation holds changes whose people count toward approval,
// so a territory redraw invalidates the memoised approval figures.
this._touchPolitics();
+11
View File
@@ -77,6 +77,17 @@ export const visibilityMethods = {
return this._serializedExplored.get(civ);
},
// The viewer's currently visible tiles, flattened for the wire. The whole
// visible set is rebuilt only on a visibility refresh, which bumps
// `_visibleVersion`, so an unchanged fog is not re-walked on every broadcast.
_cachedVisible(civ) {
const cached = this._serializedVisible.get(civ);
if (cached && cached.version === this._visibleVersion) return cached.value;
const value = serializeSet(this.visible.get(civ) || new Set());
this._serializedVisible.set(civ, { version: this._visibleVersion, value });
return value;
},
_cellsInRadius(center, radius) {
const result = [center];
const visited = new Set([key(center.x, center.y)]);
+9 -4
View File
@@ -34,7 +34,7 @@ export const warfareMethods = {
const k = key(coords.x, coords.y);
if (this.trenches.has(k)) return false;
this.trenches.add(k);
this._gdpPerCapitaCache.delete(k);
this._dropTileGdpCache(k);
this._emitChanged();
return true;
},
@@ -62,9 +62,14 @@ export const warfareMethods = {
const gdp = this.getPlayerGdp(civ);
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
const money = this._siteForecastBudget(civ, proto, proto.buildCost);
this._reserveSiteBudget(civ, money);
// A resource producer is a private business: it funds its construction from
// its own agent, borrowing from the central bank as it buys, so the state
// pays nothing. Military works are public and come out of the treasury.
const isPrivate = this._isProductionAgent(proto);
if (isPrivate) this._ensureBuildingAgent(k);
else this._reserveSiteBudget(civ, money);
const site = this._openConstructionSite({
civ, coords, proto, kind: "build", money, buildCost: proto.buildCost,
civ, coords, proto, kind: "build", money, buildCost: proto.buildCost, isPrivate,
});
this._kickoffSite(site);
this._emitChanged();
@@ -91,7 +96,7 @@ export const warfareMethods = {
}
if (!removed) return false;
this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k);
this._dropTileGdpCache(k);
this._emitChanged();
return true;
},
+1 -1
View File
@@ -332,7 +332,7 @@ export const worldMethods = {
const perHead = base.gdp / base.population;
this._productionBalance.set(i, perHead > 0 ? this._targetProductionPerHead / perHead : 1);
}
this._gdpPerCapitaCache.clear();
this._clearTileGdpCache();
},
_isLand(coords) {
+53
View File
@@ -2,6 +2,21 @@ import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
import { BUDGET_HISTORY_MONTHS } from "../shared/game_state/budget.js";
import { key } from "../shared/hex.js";
// Plants a state-owned work on a free land tile, so the day's works upkeep is
// billed to the treasury rather than settled by a private producer's cash.
function placeFortification(state, civ = 0) {
const coords = state.landCells.find((c) => {
if (state.civAt(c) !== civ || state.cityAt(c)) return false;
const k = key(c.x, c.y);
return !state.tileImprovements.has(k) && !state.roads.has(k) && !state.railways.has(k);
});
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, civ);
state.tileImprovementHp.set(k, 1000);
}
export class BudgetTest extends TestCase {
test_the_opening_month_is_the_only_report() {
@@ -50,6 +65,7 @@ export class BudgetTest extends TestCase {
test_daily_upkeep_records_cash_and_market_value() {
const state = smallState();
placeFortification(state);
state.advanceHour();
for (let i = 0; i < 23; i++) state.advanceHour();
const report = state.budgetMonthReports(0).at(-1);
@@ -111,6 +127,19 @@ export class BudgetTest extends TestCase {
this.assertApprox(steel[1], 300);
}
test_expense_rows_carry_the_quantity_that_was_bought() {
const state = smallState();
state._recordBudgetCash(0, "tilebuild", -1_000);
state._recordBudgetCategoryResource(0, "tilebuild", "steel", 1_000, "Road", 50);
const report = state.budgetMonthReports(0).at(-1);
const tilebuild = report.expenses.find((entry) => entry.id === "tilebuild");
const steel = tilebuild.buys.find((entry) => entry[0] === "steel");
this.assertApprox(steel[1], 1_000);
this.assertApprox(steel[3], 50, 1e-9, "the quantity travels with the money");
this.assertApprox(steel[1] / steel[3], 20, 1e-9, "so the price per unit can be read");
this.assertApprox(steel[2][0][2], 50, 1e-9, "the source carries its quantity too");
}
test_expense_rows_name_the_buildings_that_consumed_a_resource() {
const state = smallState();
state._recordBudgetCash(0, "buildings", -500);
@@ -145,6 +174,29 @@ export class BudgetTest extends TestCase {
const buildings = report.expenses.find((entry) => entry.id === "buildings");
this.assertNotNull(buildings, "building upkeep is recorded");
this.assertGreater(buildings.buys.length, 0, "the row names the resource it bought");
const steel = buildings.buys.find((entry) => entry[0] === "steel");
this.assertGreater(steel[3], 0, "the row knows how much steel it wore");
const sourceQty = steel[2].reduce((sum, source) => sum + (source[2] || 0), 0);
this.assertApprox(sourceQty, steel[3], 1e-9, "the sources carry the worn quantity too");
}
test_upkeep_splits_the_worn_quantity_across_its_sources() {
const state = smallState();
state._recordBudgetCash(0, "transport", -900);
state._recordResourceBySource(
0, "transport", "steel", 900,
[{ label: "Roads", steel: 2 }, { label: "Railways", steel: 1 }],
"steel", 300
);
const report = state.budgetMonthReports(0).at(-1);
const transport = report.expenses.find((entry) => entry.id === "transport");
const steel = transport.buys.find((entry) => entry[0] === "steel");
this.assertApprox(steel[3], 300, 1e-9, "the resource line carries the total quantity");
const sources = new Map(steel[2].map((source) => [source[0], source]));
this.assertApprox(sources.get("Roads")[1], 600, 1e-9);
this.assertApprox(sources.get("Roads")[2], 200, 1e-9, "two thirds of the wear");
this.assertApprox(sources.get("Railways")[1], 300, 1e-9);
this.assertApprox(sources.get("Railways")[2], 100, 1e-9, "one third of the wear");
}
test_the_snapshot_ships_the_month_reports() {
@@ -155,5 +207,6 @@ export class BudgetTest extends TestCase {
const current = months[months.length - 1];
this.assertTrue(current.isCurrent, "the last shipped report is the live month");
this.assertTrue(Array.isArray(current.expenses), "the report carries its expenses");
this.assertEqual(current.label, "January 2000", "the shipped month is named");
}
}
+100 -6
View File
@@ -17,6 +17,19 @@ function bareOwnedLand(state, civ = 0) {
});
}
// Makes `id` the only tile improvement in the world, so the works-upkeep math
// is not disturbed by the producers the world opens with.
function soleWork(state, coords, id) {
state.tileImprovements.clear();
state.tileImprovementOwner.clear();
state.tileImprovementHp.clear();
if (state.buildingAgents) state.buildingAgents.clear();
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, id);
state.tileImprovementOwner.set(k, 0);
return k;
}
// Registers a construction site literal, so the site tick can be exercised
// without a full build order.
function makeSite(state, coords, overrides = {}) {
@@ -121,6 +134,26 @@ export class ConstructionSiteTest extends TestCase {
this.assertLess(site.budget, 1e9, "the site's own budget paid for the materials");
}
test_a_site_files_what_it_bought_and_what_it_paid() {
const state = smallState();
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 1000;
const coords = bareOwnedLand(state);
const site = makeSite(state, coords, {
cityId: city.id,
budget: 1e9,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state._gatherSiteMaterials(site);
const report = state.budgetMonthReports(0).at(-1);
const tilebuild = report.expenses.find((entry) => entry.id === "tilebuild");
this.assertNotNull(tilebuild, "the tile-improvement expense is recorded");
const steel = tilebuild.buys.find((entry) => entry[0] === "steel");
this.assertNotNull(steel, "the steel it bought is broken out");
this.assertApprox(steel[3], 100, 1e-6, "the quantity bought travels with the money");
this.assertGreater(steel[1], 0, "and so does what it paid for it");
}
test_a_day_that_buys_nothing_stalls_the_site() {
const state = smallState();
clearMaterials(state);
@@ -300,6 +333,71 @@ export class ConstructionSiteTest extends TestCase {
this.assertNotNull(state.constructionSiteAt(coords));
}
test_a_private_site_borrows_instead_of_touching_the_treasury() {
const state = smallState();
const city = cityOf(state, 0);
state.getCityResourceStock(city).steel = 1000;
const coords = state.landCells.find((cell) => {
const k = key(cell.x, cell.y);
return state.civAt(cell) === 0 && !state.tileImprovements.has(k)
&& !state.constructionSiteAt(cell);
});
const k = key(coords.x, coords.y);
const agent = state._ensureBuildingAgent(k);
agent.cash = 0;
const treasury = 5e8;
state.budgets.set(0, treasury);
const site = makeSite(state, coords, {
id: "steel_mill",
cityId: city.id,
budget: 0,
private: true,
needed: { steel: 100, hightech: 0, energy: 0 },
});
state._gatherSiteMaterials(site);
this.assertApprox(site.bought.steel, 100, 1e-6, "the agent bought the steel");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury was not touched");
this.assertGreater(agent.debt, 0, "the agent borrowed the money");
const report = state.budgetMonthReports(0).at(-1);
this.assertEqual(
report.expenses.find((entry) => entry.id === "tilebuild"),
undefined,
"a private work files no public tile-improvement expense"
);
}
test_ordering_a_producer_spends_no_treasury_and_creates_its_agent() {
const state = smallState();
state.tileImprovements.clear();
state.tileImprovementOwner.clear();
state.tileImprovementHp.clear();
if (state.buildingAgents) state.buildingAgents.clear();
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stock = state.getCityResourceStock(city);
stock.steel = 1e9;
stock.hightech = 1e9;
stock.energy = 1e9;
}
const treasury = 1e9;
state.budgets.set(0, treasury);
const coords = state.landCells.find(
(cell) => state.civAt(cell) === 0 && !state.cityAt(cell)
);
const k = key(coords.x, coords.y);
this.assertTrue(state.requestBuildTileImprovement(0, coords, "steel_mill"));
const agent = state.buildingAgents.get(k);
this.assertNotNull(agent, "the producer's agent opens with the order");
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury pays nothing");
this.assertGreater(agent.debt, 0, "the work is funded by a bank loan");
const report = state.budgetMonthReports(0).at(-1);
this.assertEqual(
report.expenses.find((entry) => entry.id === "tilebuild"),
undefined,
"nothing is filed as a public tile-improvement expense"
);
}
test_a_finished_site_keeps_its_leftover_as_improvement_cash() {
const state = smallState();
const city = cityOf(state, 0);
@@ -325,9 +423,7 @@ export class ConstructionSiteTest extends TestCase {
test_a_works_reserve_pays_toward_its_own_upkeep() {
const state = smallState();
const coords = bareOwnedLand(state);
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, 0);
const k = soleWork(state, coords, "fortifications");
state.improvementCash.set(k, 1e9);
const proto = tileImprovementById("fortifications");
const costs = protoUpkeep(proto, proto.buildCost || 0);
@@ -344,9 +440,7 @@ export class ConstructionSiteTest extends TestCase {
test_a_works_reserve_only_covers_what_its_money_reaches() {
const state = smallState();
const coords = bareOwnedLand(state);
const k = key(coords.x, coords.y);
state.tileImprovements.set(k, "fortifications");
state.tileImprovementOwner.set(k, 0);
const k = soleWork(state, coords, "fortifications");
const proto = tileImprovementById("fortifications");
const costs = protoUpkeep(proto, proto.buildCost || 0);
const unit = state._marketUnitCost("steel");
+18 -2
View File
@@ -2,8 +2,24 @@ import { TestCase } from "./framework/test_case.js";
import { GameServer } from "../server/game_server.js";
import { CIVILISATIONS } from "../shared/data.js";
import { adjacentLand, SEED, grantBuilding, smallConfig } from "./framework/helpers.js";
import { STARTING_FORCES } from "../shared/game_state/scenario.js";
import { key } from "../shared/hex.js";
// What the opening order of battle should total on the fixture's world.
function expectedStartingUnits(state) {
let total = 0;
for (const city of state.cities) {
total += STARTING_FORCES.infantryPerCity + STARTING_FORCES.artilleryPerCity;
if (state.hasCityBuilding(city, "port")) {
total += STARTING_FORCES.launchesPerPort + STARTING_FORCES.battleshipsPerPort;
}
if (city.isCapital) {
total += STARTING_FORCES.fightersPerCapital + STARTING_FORCES.bombersPerCapital;
}
}
return total;
}
function fakeNetwork() {
return {
peers: new Set(),
@@ -42,7 +58,7 @@ export class GameServerTest extends TestCase {
this.assertTrue(server.isConfigured());
this.assertNotNull(server.state);
this.assertEqual(server.hostCiv, 0);
this.assertSize(server.state.units, 2);
this.assertSize(server.state.units, expectedStartingUnits(server.state));
this.assertSize(server.state.civilisations, 2);
}
@@ -70,7 +86,7 @@ export class GameServerTest extends TestCase {
this.assertNull(delta.territory, "unchanged territory is left out");
this.assertNull(delta.regions, "unchanged regions are left out");
this.assertNull(delta.population, "unchanged population is left out");
this.assertNull(delta.gdpBaseline, "the static growth baseline is left out");
this.assertNull(delta.productionBaseline, "the static growth baseline is left out");
this.assertNull(delta.tileEthnicity, "unchanged ethnicity is left out");
this.assertNull(delta.cityStats, "unchanged city stats are left out");
this.assertNull(delta.visible, "unchanged visibility is left out");
+40 -2
View File
@@ -8,6 +8,8 @@ import {
resourceBuildingUpgradeMoneyCost,
tileImprovementById,
} from "../shared/data.js";
import { protoUpkeep } from "../shared/resources.js";
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
// A production building of `id` on the first land tile of `civ`'s that is not a
// city, wired into the agent maps and with an empty stock. The tile may already
@@ -89,6 +91,37 @@ export class ProductionBuildingAgentTest extends TestCase {
this.assertTrue(state._isProductionAgent(mill), "a mill sells steel");
}
test_a_producer_borrows_what_its_own_wear_cannot_cover() {
const state = smallState();
const { k, agent } = placeProducer(state, "steel_mill");
const proto = tileImprovementById("steel_mill");
const costs = protoUpkeep(proto, proto.buildCost || 0);
const value = costs.steel * state._marketUnitCost("steel") +
costs.hightech * state._marketUnitCost("hightech");
// Half a day's wear in the account: the rest becomes a central-bank loan.
agent.cash = value / 2;
const categories = {
works: { steel: costs.steel, hightech: costs.hightech, sources: [] },
};
state._applyWorksMaintenance(0, categories);
this.assertApprox(agent.cash, 0, 1e-6, "the cash was spent first");
this.assertApprox(agent.debt, value / 2, 1e-6, "the rest is a loan");
this.assertApprox(categories.works.steel, 0, 1e-6, "the state is not billed for a producer");
this.assertApprox(categories.works.hightech, 0, 1e-6, "nor for its high-tech");
}
test_a_producers_loan_compounds_and_is_repaid_from_its_cash() {
const state = smallState();
const { agent } = placeProducer(state, "steel_mill");
agent.debt = 1_000;
const rate = state.getCentralBankRate(0) / DAYS_PER_YEAR;
// Exactly one day's interest plus the principal, so repayment clears it.
agent.cash = 1_000 * (1 + rate);
state._tickMoney();
this.assertApprox(agent.debt, 0, 1e-6, "the loan is repaid");
this.assertApprox(agent.cash, 0, 1e-6, "the cash it held paid it down");
}
test_growth_needs_the_recent_half_of_the_window_to_beat_the_older_half() {
const state = smallState();
const { agent } = placeProducer(state, "steel_mill");
@@ -174,14 +207,17 @@ export class ProductionBuildingAgentTest extends TestCase {
const state = smallState();
const { coords, agent } = placeProducer(state, "steel_mill");
agent.cash = 4_000_000;
agent.debt = 250_000;
const stats = state.snapshot(0).viewerStats;
this.assertApprox(stats.privateBuildingCash, state.getPrivateBuildingCash(0));
this.assertApprox(stats.privateBuildingDebt, 250_000, 1e-6, "the loan is shipped too");
const entry = stats.productionBuildings.find(
(item) => item.coords[0] === coords.x && item.coords[1] === coords.y
);
this.assertNotNull(entry, "the building is listed on its own row");
this.assertEqual(entry.level, 0);
this.assertApprox(entry.cash, 4_000_000, 1e-6);
this.assertApprox(entry.debt, 250_000, 1e-6);
}
}
@@ -192,7 +228,7 @@ export class PowerMarketTest extends TestCase {
const converter = placeProducer(state, "steel_mill");
const city = cityOf(state, 0);
const plantBefore = plant.agent.cash;
const converterBefore = converter.agent.cash;
const converterDebtBefore = converter.agent.debt;
const regionBefore = state.getRegionCashValue(city);
const price = state.getResourcePrice("energy");
const ledger = new Map([[0, {
@@ -213,7 +249,9 @@ export class PowerMarketTest extends TestCase {
}]]);
state._settleGridPower(ledger);
this.assertApprox(plant.agent.cash - plantBefore, 300 * price, 1e-6, "the plant is paid");
this.assertApprox(converter.agent.cash - converterBefore, -200 * price, 1e-6, "the converter pays");
this.assertApprox(converter.agent.debt - converterDebtBefore, 200 * price, 1e-6,
"the converter borrows to pay");
this.assertApprox(converter.agent.cash, 0, 1e-6, "and its cash stays out of the red");
this.assertApprox(state.getRegionCashValue(city) - regionBefore, -100 * price, 1e-6,
"the region pays");
this.assertApprox(plant.agent.salesToday, 300, 1e-6, "the sale feeds the demand trend");
+117
View File
@@ -0,0 +1,117 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import {
STARTING_FORCES,
STARTING_RESERVE_DAYS,
} from "../shared/game_state/scenario.js";
import { STORABLE_RESOURCE_IDS } from "../shared/data.js";
function unitsWithProto(state, protoId) {
const index = state.protoUnits.findIndex((unit) => unit.id === protoId);
return state.units.filter((unit) => unit.proto === index);
}
function countAt(state, protoId, coords) {
return unitsWithProto(state, protoId).filter(
(unit) => unit.coords.x === coords.x && unit.coords.y === coords.y
).length;
}
export class ScenarioTest extends TestCase {
test_every_city_opens_with_a_barracks_and_the_capital_an_airport() {
const state = smallState();
state.applyStartingScenario();
const barracks = state.buildingIndexById("barracks");
const airport = state.buildingIndexById("airport");
for (const city of state.cities) {
this.assertEqual(state.getCityBuildingLevel(city, barracks), 1, "the city barracks");
this.assertEqual(
state.getCityBuildingLevel(city, airport),
city.isCapital ? 1 : 0,
`the airport in ${city.name}`
);
}
}
test_every_city_opens_with_its_infantry_and_artillery() {
const state = smallState();
state.applyStartingScenario();
for (const city of state.cities) {
this.assertEqual(
countAt(state, "modern_infantry", city.coords),
STARTING_FORCES.infantryPerCity,
`infantry in ${city.name}`
);
this.assertEqual(
countAt(state, "artillery", city.coords),
STARTING_FORCES.artilleryPerCity,
`artillery in ${city.name}`
);
}
}
test_every_port_launches_its_flotilla_onto_the_water() {
const state = smallState();
state.applyStartingScenario();
for (let civ = 0; civ < state.civilisations.length; civ++) {
const ports = state.cities.filter(
(city) => city.civ === civ && state.hasCityBuilding(city, "port")
).length;
const launches = unitsWithProto(state, "launch").filter((unit) => unit.civ === civ);
const battleships = unitsWithProto(state, "battleship").filter((unit) => unit.civ === civ);
this.assertEqual(launches.length, STARTING_FORCES.launchesPerPort * ports, `launches of civ ${civ}`);
this.assertEqual(
battleships.length,
STARTING_FORCES.battleshipsPerPort * ports,
`battleships of civ ${civ}`
);
for (const unit of launches.concat(battleships)) {
const tile = state.tiles[`${unit.coords.x},${unit.coords.y}`];
this.assertEqual(tile && tile.terrainClass, "Sea", "the warship floats");
}
}
}
test_the_capital_fields_the_air_force() {
const state = smallState();
state.applyStartingScenario();
for (const city of state.cities) {
if (!city.isCapital) continue;
this.assertEqual(
countAt(state, "jet_fighter", city.coords),
STARTING_FORCES.fightersPerCapital,
`fighters in ${city.name}`
);
this.assertEqual(
countAt(state, "bomber", city.coords),
STARTING_FORCES.bombersPerCapital,
`bombers in ${city.name}`
);
}
}
test_each_region_opens_with_a_year_of_its_consumption() {
const state = smallState();
state.applyStartingScenario();
for (const city of state.cities) {
const consumption = state.getRegionResourceInfo(city).consumption;
const stock = state.getCityResourceStock(city);
for (const id of STORABLE_RESOURCE_IDS) {
this.assertApprox(
stock[id],
(consumption[id] || 0) * STARTING_RESERVE_DAYS,
1e-6,
`${id} in ${city.name}`
);
}
}
}
test_the_scenario_replaces_any_starting_units() {
const state = smallState();
state.applyStartingScenario();
const once = state.units.length;
state.applyStartingScenario();
this.assertEqual(state.units.length, once, "re-applying does not stack a second army");
}
}
+18 -3
View File
@@ -98,12 +98,17 @@ export class TestingModeTest extends TestCase {
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
const levelBefore = server.state.getCityBuildingLevel(city, barracks);
const budgetBefore = server.state.getBudget(0);
this.assertTrue(
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
"the order is accepted"
);
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 1, "the building went up");
this.assertEqual(
server.state.getCityBuildingLevel(city, barracks),
levelBefore + 1,
"the building went up"
);
this.assertEqual(server.state.getBudget(0), budgetBefore, "it was free");
this.assertTrue(server.state.testing, "the snapshot flag is set");
}
@@ -113,12 +118,17 @@ export class TestingModeTest extends TestCase {
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
const levelBefore = server.state.getCityBuildingLevel(city, barracks);
const budgetBefore = server.state.getBudget(0);
this.assertTrue(
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
"the order is still processed"
);
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 0, "it only went into the queue");
this.assertEqual(
server.state.getCityBuildingLevel(city, barracks),
levelBefore,
"it only went into the queue"
);
this.assertLess(server.state.getBudget(0), budgetBefore, "the normal cost was charged");
this.assertFalse(server.state.testing, "the testing flag stays off");
}
@@ -129,12 +139,17 @@ export class TestingModeTest extends TestCase {
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
const levelBefore = server.state.getCityBuildingLevel(city, barracks);
const budgetBefore = server.state.getBudget(0);
this.assertTrue(
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
"the order is accepted"
);
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 1, "the building went up");
this.assertEqual(
server.state.getCityBuildingLevel(city, barracks),
levelBefore + 1,
"the building went up"
);
this.assertEqual(server.state.getBudget(0), budgetBefore, "it was free");
this.assertFalse(server.state.testing, "the game itself stays a normal game");
}
+39
View File
@@ -0,0 +1,39 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
// The opening settle: the server runs the world forward until commodity prices
// hold steady, so a fresh game is not served mid-shakeout.
export class WarmupTest extends TestCase {
test_prices_are_not_steady_before_the_window_is_full() {
const state = smallState();
this.assertFalse(state.pricesAreSteady(7, 0.1), "a fresh market has no history to judge");
}
test_settling_stops_once_prices_hold_steady() {
const state = smallState();
const days = state.warmUpToStability({ maxDays: 90 });
this.assertLess(days, 90, "it stopped early rather than at the cap");
this.assertGreaterOrEqual(days, 7, "it ran at least the seven-day window");
this.assertTrue(state.pricesAreSteady(7, 0.1), "prices are steady when it decides to stop");
}
test_settling_stops_at_the_cap_when_it_never_steadies() {
const state = smallState();
const days = state.warmUpToStability({ maxDays: 3 });
this.assertEqual(days, 3, "the cap bounds a world that never settles");
this.assertEqual(state.totalHours, 3 * HOURS_PER_DAY, "three days were actually run");
}
test_a_zero_cap_skips_the_settle() {
const state = smallState();
this.assertEqual(state.warmUpToStability({ maxDays: 0 }), 0, "nothing is run");
this.assertEqual(state.totalHours, 0, "the clock did not move");
}
test_a_fixed_warm_up_still_runs_the_exact_days() {
const state = smallState();
state.warmUp(5);
this.assertEqual(state.totalHours, 5 * HOURS_PER_DAY, "a fixed settle runs every day asked for");
}
}