");
@@ -1293,6 +1368,25 @@ export class NationModal {
this.$diplomacyList.append($table);
}
+ // A nation's money figure, in that nation's own currency, with the viewer's
+ // equivalent at the snapshot's instant rate in a discreet parenthesis beside
+ // it. Our own figures are already in our money, so they carry no conversion.
+ _moneyCell(value, civ, self) {
+ const $td = $("
");
+ const info = self ? null : currencyFor(civ);
+ if (!info) {
+ $td.text(`${currencySymbol()}${compact(value)}`);
+ return $td;
+ }
+ $td.append(
+ document.createTextNode(`${info.symbol}${compact(value * rateToNational(civ))}`)
+ );
+ $td.append(
+ $("").text(` (${currencySymbol()}${compact(value)})`)
+ );
+ return $td;
+ }
+
// Clicking a header sorts by it, reversing the order when it is already the
// active column.
_sortDiplomacyBy(key) {
diff --git a/client/js/ui/line_chart.js b/client/js/ui/line_chart.js
new file mode 100644
index 0000000..ed9e5da
--- /dev/null
+++ b/client/js/ui/line_chart.js
@@ -0,0 +1,84 @@
+// A small multi-series line chart in SVG, used by the central bank's commodity
+// index. Pure DOM, so it works in jsdom and needs no canvas.
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+
+// Builds the chart. `series` is [{ label, colour, values }] with values oldest
+// first; every series shares one x axis. `options.baseline` draws the reference
+// line (100 for a price index), and `options.xLabel(i)` labels the ends.
+export function lineChart(series, options = {}) {
+ const width = options.width || 320;
+ const height = options.height || 150;
+ const pad = { top: 12, right: 10, bottom: 20, left: 40 };
+ const baseline = options.baseline === undefined ? 100 : options.baseline;
+ const svg = document.createElementNS(SVG_NS, "svg");
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ svg.setAttribute("width", String(width));
+ svg.setAttribute("height", String(height));
+ svg.setAttribute("class", "line-chart");
+ const rows = (series || []).filter((row) => row.values && row.values.length > 0);
+ if (rows.length === 0) return svg;
+ const count = Math.max(...rows.map((row) => row.values.length));
+ let min = baseline;
+ let max = baseline;
+ for (const row of rows) {
+ for (const value of row.values) {
+ if (value < min) min = value;
+ if (value > max) max = value;
+ }
+ }
+ if (max - min < 1e-6) {
+ min -= 1;
+ max += 1;
+ }
+ const span = max - min;
+ const plotW = width - pad.left - pad.right;
+ const plotH = height - pad.top - pad.bottom;
+ const x = (i) => pad.left + (count <= 1 ? plotW / 2 : (i / (count - 1)) * plotW);
+ const y = (value) => pad.top + (1 - (value - min) / span) * plotH;
+
+ const baseLine = document.createElementNS(SVG_NS, "line");
+ baseLine.setAttribute("x1", String(pad.left));
+ baseLine.setAttribute("x2", String(width - pad.right));
+ baseLine.setAttribute("y1", String(y(baseline)));
+ baseLine.setAttribute("y2", String(y(baseline)));
+ baseLine.setAttribute("class", "line-chart-baseline");
+ svg.appendChild(baseLine);
+
+ for (const [value, anchor] of [[max, "start"], [min, "end"]]) {
+ const label = document.createElementNS(SVG_NS, "text");
+ label.setAttribute("x", String(pad.left - 5));
+ label.setAttribute("y", String(y(value) + (anchor === "start" ? 3 : 0)));
+ label.setAttribute("text-anchor", "end");
+ label.setAttribute("class", "line-chart-label");
+ label.textContent = String(Math.round(value));
+ svg.appendChild(label);
+ }
+
+ for (const row of rows) {
+ const polyline = document.createElementNS(SVG_NS, "polyline");
+ polyline.setAttribute(
+ "points",
+ row.values.map((value, i) => `${x(i)},${y(value)}`).join(" ")
+ );
+ polyline.setAttribute("fill", "none");
+ polyline.setAttribute("stroke", row.colour || "#ffffff");
+ polyline.setAttribute("stroke-width", "1.6");
+ polyline.setAttribute("class", "line-chart-line");
+ svg.appendChild(polyline);
+ }
+
+ if (options.xLabel) {
+ for (const i of [0, count - 1]) {
+ if (count <= 1 && i !== 0) continue;
+ const label = document.createElementNS(SVG_NS, "text");
+ label.setAttribute("x", String(x(i)));
+ label.setAttribute("y", String(height - 6));
+ label.setAttribute("text-anchor", i === 0 ? "start" : "end");
+ label.setAttribute("class", "line-chart-label");
+ label.textContent = options.xLabel(i);
+ svg.appendChild(label);
+ }
+ }
+ return svg;
+}
diff --git a/server/game_server.js b/server/game_server.js
index 4671cd1..cb0ddff 100644
--- a/server/game_server.js
+++ b/server/game_server.js
@@ -22,6 +22,7 @@ const DELTA_COLLECTIONS = [
"cityStats",
"visible",
"resourceGraph",
+ "migrationGraph",
];
// A wire move order carrying more units than this is split into chunks that
diff --git a/shared/data/map_config.js b/shared/data/map_config.js
index fa598ca..045bdce 100644
--- a/shared/data/map_config.js
+++ b/shared/data/map_config.js
@@ -30,13 +30,16 @@ export const MAP_CONFIG = {
lacunarity: 2.0,
gain: 0.5,
},
- seaLevelStep: 0.05,
+ seaLevelStep: 0.01,
// A body of land has to reach this fraction of the map to count as a
// continent while the sea level is being chosen, so speckle cannot stand in
- // for one. The water is never lowered past `maxLandFraction` either, so a
- // single dome cannot swallow the whole map before the search settles.
+ // for one.
minContinentFraction: 0.01,
- maxLandFraction: 0.6,
+ // The share of the ice-free tiles the water search aims to leave as land.
+ // The water is lowered in `seaLevelStep` steps and the level whose land is
+ // closest to this fraction wins; the ocean is then joined into one body, so
+ // the map keeps a single connected sea whatever the continents do.
+ landFraction: 0.6,
// A few small islands are stamped into open water after the continents have
// settled. They never join a continent or sway the water search, and each is
// kept under `minCapitalLandmass`. The count is scaled by map area (so the
@@ -57,19 +60,11 @@ export const MAP_CONFIG = {
falloffEnabled: false,
falloffStart: 0.55,
falloffStrength: 0.6,
- iceLatitude: 0.9,
- tundraLatitude: 0.66,
- // The polar ice and tundra edges are bent by a finer noise field so the
- // sheets are jagged rather than straight bands. `iceRoughness` is how far (in
- // latitude) the edges may wander; the equatorward core beyond that bound is
- // always ice (and always tundra below it).
- iceNoise: {
- frequency: 0.13,
- octaves: 2,
- lacunarity: 2.0,
- gain: 0.5,
- },
- iceRoughness: 0.06,
+ // The polar ice is exactly `iceRows` rows at each edge of the map, and the
+ // tundra fringes it for `tundraRows` rows more. Fixed bands, so the sheets
+ // take only the most extreme rows and never wander deep toward the equator.
+ iceRows: 3,
+ tundraRows: 3,
// A finer field that roughens the coastline on top of the detail noise, and
// how strongly it bends the shore away from the smooth dome outline.
coastNoise: {
@@ -102,6 +97,11 @@ export const MAP_CONFIG = {
// leaves exactly this many landmasses.
minContinents: 3,
connectOceans: true,
+ // How many nations open with a chip foundry, the only high-tech works the
+ // opening economy ever gets. They are spread across distinct nations, drawn
+ // from the world seed, and never the same nation twice. A nation with no land
+ // to host one simply goes without.
+ startingChipFabs: 2,
citiesPerCiv: 10,
// Minimum distance between any two cities during generation (never below 5).
citySpacing: 5,
diff --git a/shared/game_state/monthly.js b/shared/game_state/monthly.js
index a92706d..572a756 100644
--- a/shared/game_state/monthly.js
+++ b/shared/game_state/monthly.js
@@ -41,6 +41,8 @@ export const monthlyMethods = {
this._monthIndexNow = index;
this._monthStartHistory.push({ index, stats: this._currentMonthStats });
if (this._monthStartHistory.length > 13) this._monthStartHistory.shift();
+ // The commodity price series gains this month's opening price.
+ this._sampleResourcePrices();
// The month that just ended becomes a browsable budget digest.
this._rolloverBudget(previous);
},
diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js
index 6db4f14..0512dc9 100644
--- a/shared/game_state/politics.js
+++ b/shared/game_state/politics.js
@@ -45,6 +45,9 @@ const ETHNIC_FALLOFF = 0.5;
const ETHNIC_OWNER_WEIGHT = 4;
// Diaspora shares below this are dropped, keeping the snapshot small.
const ETHNIC_MIN_SHARE = 0.03;
+// How many past days the city-to-city migration graph sums, so the population
+// map draws the flow over the last month rather than a single day.
+const MIGRATION_GRAPH_DAYS = 30;
export const politicsMethods = {
// ------------------------------------------------------- lifecycle --
@@ -56,6 +59,13 @@ export const politicsMethods = {
for (let civ = 0; civ < this.civilisations.length; civ++) {
this.policies.set(civ, []);
}
+ // City-to-city migration for the population map: the current day's flows
+ // keyed "fromCityId:toCityId", the completed days behind them (a rolling
+ // month), and a version that lets a unchanged graph be delta-dropped.
+ this._migrationDayFlows = new Map();
+ this._migrationFlowDays = [];
+ this._migrationFlowVersion = 0;
+ this._serializedMigrationGraphCache = null;
},
_tickPolitics() {
@@ -652,6 +662,9 @@ export const politicsMethods = {
// Runs one day of migration and rebuilds the flow record the UI reports.
// `migrations[civ]` holds the people who arrived and left during that day.
_tickMigration() {
+ // Close out the day just ended: its city-to-city flows join the rolling
+ // month the population map reads, and a fresh day starts empty.
+ this._rollMigrationFlows();
this.migrations = new Map();
this._migrationDeltas = { civ: new Map(), city: new Map(), tile: new Map() };
for (const [civ, record] of this.policies) {
@@ -983,6 +996,47 @@ export const politicsMethods = {
counts.set(ethnicity, (counts.get(ethnicity) || 0) + amount);
},
+ // Archives the day that just ended into the rolling month and opens a fresh
+ // day. Only the last `MIGRATION_GRAPH_DAYS` days are kept.
+ _rollMigrationFlows() {
+ this._migrationFlowDays.push(this._migrationDayFlows);
+ if (this._migrationFlowDays.length > MIGRATION_GRAPH_DAYS) {
+ this._migrationFlowDays.shift();
+ }
+ this._migrationDayFlows = new Map();
+ this._migrationFlowVersion += 1;
+ },
+
+ // Tallies one city-to-city movement for the migration graph. Same-city and
+ // unknown flows are ignored, so the graph only carries real journeys.
+ _addMigrationFlow(fromCityId, toCityId, amount) {
+ if (!(amount > 0) || fromCityId === toCityId) return;
+ if (fromCityId === undefined || toCityId === undefined || fromCityId < 0 || toCityId < 0) {
+ return;
+ }
+ const flowKey = `${fromCityId}:${toCityId}`;
+ this._migrationDayFlows.set(
+ flowKey,
+ (this._migrationDayFlows.get(flowKey) || 0) + amount
+ );
+ this._migrationFlowVersion += 1;
+ },
+
+ // The people who moved between each pair of cities over the rolling month,
+ // keyed "fromCityId:toCityId", as a Map. Summed fresh each time the graph is
+ // serialised; the memo on the version keeps that cheap.
+ _migrationFlowTotals() {
+ const totals = new Map();
+ const add = (flows) => {
+ for (const [flowKey, amount] of flows) {
+ totals.set(flowKey, (totals.get(flowKey) || 0) + amount);
+ }
+ };
+ for (const day of this._migrationFlowDays) add(day);
+ add(this._migrationDayFlows);
+ return totals;
+ },
+
// A people forced out drift across the border, preferring neighbours of their
// own ethnicity.
_migrateExpelled(civ, eth) {
@@ -1077,6 +1131,7 @@ export const politicsMethods = {
const toCity = this.regionCityAt(parseKey(toK));
if (fromCity) this._bumpDelta(this._migrationDeltas.city, fromCity.id, eth, -amount);
if (toCity) this._bumpDelta(this._migrationDeltas.city, toCity.id, eth, amount);
+ if (fromCity && toCity) this._addMigrationFlow(fromCity.id, toCity.id, amount);
this._bumpDelta(this._migrationDeltas.tile, fromK, eth, -amount);
this._bumpDelta(this._migrationDeltas.tile, toK, eth, amount);
},
diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js
index c743ed0..5c71816 100644
--- a/shared/game_state/resources.js
+++ b/shared/game_state/resources.js
@@ -50,13 +50,13 @@ const SUPPLIER_RESERVE_DAYS = 2;
// The producers a fresh nation may be seeded with. Energy uses the large
// fossil plants so a nation needs few works to light its industry; the luxury
-// pick is randomised between the two mines. A producer locked behind research
-// (fusion, and the chip foundry, which no opening need calls for) never
-// appears.
+// pick is randomised between the two mines. High-tech is deliberately absent:
+// a chip foundry is not covered by these passes but placed by hand for the two
+// nations that open with one (see `_seedResourceBuildings`). Fusion, locked
+// behind research, never appears either.
const SEED_PRODUCERS = {
steel: ["steel_mill"],
luxury: ["diamond_mine", "gold_mine"],
- hightech: ["chip_foundry"],
energy: ["coal_power_plant", "natural_gas_power_plant", "oil_power_plant"],
};
// How many top-up passes the start-of-game seeding makes before it gives up on
@@ -84,6 +84,12 @@ export const resourceMethods = {
this.resourcePrices.set(id, RESOURCE_MARKET_BASE[id]);
this.resourceMarketStats.set(id, { supply: 0, demand: 0, price: RESOURCE_MARKET_BASE[id] });
}
+ // The world price of every commodity at the start of each month, oldest
+ // first, so the central bank can chart an index from 100 in January 2000 and
+ // read month-on-month and year-on-year inflation. The first sample is the
+ // opening price, the index's base.
+ this.resourcePriceHistory = new Map();
+ for (const id of RESOURCE_IDS) this.resourcePriceHistory.set(id, [RESOURCE_MARKET_BASE[id]]);
this.resourceLinks = [];
this._resourceShortages = new Map();
// The food each region has bought to relay on to its neighbours, so only
@@ -276,8 +282,16 @@ export const resourceMethods = {
}
claims.push({ civ, spots, byComponent });
}
+ // Two nations open with a chip foundry -- the only high-tech the opening
+ // world makes -- so early research is a prize two powers share. The pair is
+ // drawn from the world seed and never the same nation twice; a nation with
+ // no land to host one simply goes without.
+ const fabCount = Math.max(0, this.mapConfig?.startingChipFabs ?? 2);
+ const fabCandidates = fabCount > 0 ? claims.filter((claim) => claim.spots.length > 0) : [];
+ rng.shuffle(fabCandidates);
+ const fabCivs = new Set(fabCandidates.slice(0, fabCount).map((claim) => claim.civ));
for (const claim of claims) {
- this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim);
+ this._layStartingIndustry(rng, components, foodEnergy, touchesNetwork, claim, fabCivs.has(claim.civ));
}
this._gdpPerCapitaCache.clear();
},
@@ -287,7 +301,7 @@ export const resourceMethods = {
// stops as soon as a pass changes nothing. A work off the transport network
// gets a one-tile road spur where it can, so its output can actually reach a
// city.
- _layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }) {
+ _layStartingIndustry(rng, components, foodEnergy, touchesNetwork, { civ, spots, byComponent }, chipFab = false) {
const used = new Set();
let cursor = 0;
const takeSpot = (component) => {
@@ -329,10 +343,13 @@ export const resourceMethods = {
}
return laid;
};
+ // A chip foundry is laid before the covering passes so the power seeding
+ // sees its draw and builds the grid needed to run it.
+ if (chipFab) place(tileImprovementById("chip_foundry"), undefined, 1);
for (let pass = 0; pass < SEED_MAX_PASSES; pass++) {
let changed = false;
const deficits = this._resourceSeedDeficits(civ);
- for (const id of ["steel", "luxury", "hightech"]) {
+ for (const id of ["steel", "luxury"]) {
const target = deficits[id] * SEED_MATERIAL_MARGIN;
if (target <= 0) continue;
const proto = this._pickSeedProducer(rng, id);
@@ -479,6 +496,40 @@ export const resourceMethods = {
this.commodityInflation = before > 0 ? now / before - 1 : 0;
},
+ // Snapshots every commodity's world price at the start of a new month. Called
+ // from the monthly rollover, so the series holds one opening price per month.
+ _sampleResourcePrices() {
+ if (!this.resourcePriceHistory) return;
+ for (const id of RESOURCE_IDS) {
+ const series = this.resourcePriceHistory.get(id) || [];
+ series.push(this.getResourcePrice(id));
+ this.resourcePriceHistory.set(id, series);
+ }
+ },
+
+ // 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
+ // a year earlier; through 2000 there is no earlier year, so the year's first
+ // month stands in and yearly reads as growth since the start of the game.
+ resourceInflation(id) {
+ const series = this.resourcePriceHistory ? this.resourcePriceHistory.get(id) : null;
+ const price = this.getResourcePrice(id);
+ if (!series || series.length === 0) {
+ return { index: 100, monthly: 0, yearly: 0 };
+ }
+ const base = series[0] || price;
+ const index = base > 0 ? (price / base) * 100 : 100;
+ // No previous month before February 2000, so monthly reads zero there.
+ const previous = series.length >= 2 ? series[series.length - 2] : null;
+ const yearAgo = series.length >= 13 ? series[series.length - 13] : base;
+ return {
+ index,
+ monthly: previous > 0 ? price / previous - 1 : 0,
+ yearly: yearAgo > 0 ? price / yearAgo - 1 : 0,
+ };
+ },
+
// The storage a tile has, or null when it is neither a city nor a resource
// building. Used both by the simulation and the delivery graph.
_resourceNodeAt(coords) {
diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js
index 7b90a3d..66bef0f 100644
--- a/shared/game_state/serialization.js
+++ b/shared/game_state/serialization.js
@@ -74,6 +74,9 @@ export const serializationMethods = {
campaigns: this._serializeCampaigns(),
// The people who moved in and out of each country during the last day.
migrations: this._serializeMigrations(),
+ // The city-to-city migration over the last month, for the population
+ // map's flow arrows.
+ migrationGraph: this._serializeMigrationGraph(),
// Every population's opinion of every other population and government,
// so the client can show and target the propaganda.
opinions: {
@@ -95,6 +98,10 @@ export const serializationMethods = {
// The global resource market: today's price and the supply and demand
// that moved it, for the resource panel.
resourceMarket: this._serializeResourceMarket(),
+ // The monthly world-price series per commodity and the inflation each one
+ // shows, for the central bank's index chart and its monthly/yearly rates.
+ resourcePriceHistory: this._serializeResourcePriceHistory(),
+ resourceInflation: this._serializeResourceInflation(),
// Every nation's imports and exports of each resource over the last day.
resourceTrade: this._serializeResourceTrade(),
// Each nation's indicators a month ago, for the summary change arrows;
@@ -123,6 +130,7 @@ export const serializationMethods = {
visible: this._visibleVersion,
productionBaseline: this._productionBaselineVersion,
resourceGraph: this._resourceVersion,
+ migrationGraph: this._migrationFlowVersion,
},
};
},
@@ -631,6 +639,30 @@ export const serializationMethods = {
});
},
+ // The opening world price of every commodity at the start of each month,
+ // [id, [price, ...]], oldest first. World-wide, so it travels once.
+ _serializeResourcePriceHistory() {
+ return RESOURCE_IDS.map((id) => [
+ id,
+ (this.resourcePriceHistory ? this.resourcePriceHistory.get(id) || [] : [])
+ .map((price) => roundTo(price, 3)),
+ ]);
+ },
+
+ // Each commodity's index (100 in January 2000) and its month-on-month and
+ // year-on-year inflation, for the central bank tab.
+ _serializeResourceInflation() {
+ return RESOURCE_IDS.map((id) => {
+ const info = this.resourceInflation(id);
+ return {
+ id,
+ index: roundTo(info.index, 3),
+ monthly: roundTo(info.monthly, 6),
+ yearly: roundTo(info.yearly, 6),
+ };
+ });
+ },
+
// Every nation's imports and exports of each resource over the last day, so
// the Resources tab can show a viewer's own trade and rank the world's major
// exporters and importers. Cross-nation deliveries only.
@@ -783,6 +815,29 @@ export const serializationMethods = {
return result;
},
+ // The city-to-city migration over the rolling month, as [fromX, fromY, toX,
+ // toY, people] links for the population map's arrows. World-wide, like the
+ // per-tile population the mode paints, and memoised against the flow version
+ // so an unchanged graph is not re-summed on every broadcast.
+ _serializeMigrationGraph() {
+ const cache = this._serializedMigrationGraphCache;
+ if (cache && cache.version === this._migrationFlowVersion) return cache.value;
+ const links = [];
+ for (const [flowKey, people] of this._migrationFlowTotals()) {
+ const amount = Math.round(people);
+ if (amount <= 0) continue;
+ const [fromId, toId] = flowKey.split(":").map(Number);
+ const from = this._cityById.get(fromId);
+ const to = this._cityById.get(toId);
+ if (!from || !to) continue;
+ links.push([from.coords.x, from.coords.y, to.coords.x, to.coords.y, amount]);
+ }
+ links.sort((a, b) => a[4] - b[4]);
+ const value = { links };
+ this._serializedMigrationGraphCache = { version: this._migrationFlowVersion, value };
+ return value;
+ },
+
// A dense observer x target matrix. A missing entry is the neutral baseline.
_serializeOpinionMatrix(store) {
const result = [];
diff --git a/shared/game_state/world.js b/shared/game_state/world.js
index e95a9f0..283f337 100644
--- a/shared/game_state/world.js
+++ b/shared/game_state/world.js
@@ -148,6 +148,7 @@ export const worldMethods = {
this.cities.push(city);
this._cityById.set(this._nextCityId, city);
this._nextCityId += 1;
+ return city;
},
_rebuildCityIndex() {
diff --git a/shared/map_generator.js b/shared/map_generator.js
index 97d8dfb..d7d9fd8 100644
--- a/shared/map_generator.js
+++ b/shared/map_generator.js
@@ -53,7 +53,6 @@ export class MapGenerator {
const domeRadius = this._domeRadius();
const elevations = new Map();
const ice = new Set();
- const iceNoise = new Map();
let minElevation = Infinity;
let maxElevation = -Infinity;
for (let row = 0; row < this.config.mapSize.y; row++) {
@@ -67,12 +66,7 @@ export class MapGenerator {
value += (this.config.coastRoughness || 0) * coast;
}
if (this.config.falloffEnabled) value = this._applyFalloff(coords, value, wrapped);
- let polarNoise = 0;
- if (this.config.iceNoise) {
- polarNoise = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.iceNoise);
- }
- if (this._isPolar(coords, polarNoise)) ice.add(k);
- iceNoise.set(k, polarNoise);
+ if (this._isPolar(coords)) ice.add(k);
elevations.set(k, value);
if (value < minElevation) minElevation = value;
if (value > maxElevation) maxElevation = value;
@@ -83,14 +77,14 @@ export class MapGenerator {
for (let column = 0; column < this.config.mapSize.x; column++) {
const coords = { x: column - halfX, y: row - halfY };
const k = key(coords.x, coords.y);
- const tile = this._chooseTile(coords, elevations.get(k), iceNoise.get(k));
+ const tile = this._chooseTile(coords, elevations.get(k));
this.tiles[k] = tile;
if (tile.terrainClass === "Land") this.landCells.push(coords);
}
}
this._removeSmallFeatures();
if (this.config.connectOceans !== false) this._connectOceans();
- this._placeSmallIslands(seed, iceNoise);
+ this._placeSmallIslands(seed);
// Sweep up any speck left behind, including the slivers the ocean channels
// may have cut and any island the noise thinned below the small-island size.
this._removeSmallFeatures();
@@ -115,7 +109,7 @@ export class MapGenerator {
// water search, and every candidate sits far enough from land that the whole
// blob stays clear of the coast. Each blob is sized well below
// `minCapitalLandmass`, so it reads as an island and hosts no capital.
- _placeSmallIslands(seed, iceNoise) {
+ _placeSmallIslands(seed) {
const count = this._smallIslandCount();
if (count <= 0) return;
const target = Math.max(4, Math.round(this.config.smallIslandSize || 30));
@@ -178,8 +172,7 @@ export class MapGenerator {
// enough to push it across the next hex ring and break the dice while
// still keeping every island under the capital landmass size.
if (d > radius + (irregularity - 0.5) * 1.8) continue;
- const polar = iceNoise ? iceNoise.get(k) || 0 : 0;
- this.tiles[k] = tundra && this._isTundra(coords, polar) ? tundra : plains;
+ this.tiles[k] = tundra && this._isTundra(coords) ? tundra : plains;
}
}
}
@@ -305,37 +298,47 @@ export class MapGenerator {
return field;
}
- // Lowers the water from the highest peak in `seaLevelStep` steps and keeps
- // the lowest level that still leaves exactly the wanted number of continents.
- // A continent is a connected body of land at least `minContinentFraction` of
- // the map, so lone speckle cannot count. Returns the configured `seaLevel`
- // only when no level gives an exact match.
+ // Finds the water level whose land is closest to `landFraction` of the
+ // ice-free tiles; the caller then joins the ocean into one body, so the map
+ // keeps a single connected sea. Land only grows as the water falls, so the
+ // level is found by bisection instead of sweeping every step (each probe is a
+ // full flood fill, and a sweep made generation needlessly slow). Only the
+ // fallback `seaLevel` is used when no fraction is set.
_chooseSeaLevel(elevations, ice, minElevation, maxElevation) {
- const target = this._continentCount();
+ const fraction = this.config.landFraction;
+ const iceFree = elevations.size - ice.size;
+ if (!(fraction > 0) || iceFree <= 0) return this.config.seaLevel || 0;
+ // The small islands are stamped in after the water is chosen, so allow for
+ // the land they will add; otherwise the finished map overshoots the target
+ // by their area.
+ const islandLand =
+ this._smallIslandCount() *
+ Math.max(4, Math.round(this.config.smallIslandSize || 30)) *
+ 0.9;
+ const targetLand = Math.max(0, fraction * iceFree - islandLand);
const step = Math.max(1e-4, this.config.seaLevelStep || 0.05);
const area = this.config.mapSize.x * this.config.mapSize.y;
const threshold = Math.max(1, Math.round(area * (this.config.minContinentFraction || 0)));
- const maxLand = area * (this.config.maxLandFraction || 1);
- let chosen = this.config.seaLevel || 0;
- let found = false;
- let fallback = null;
- for (let level = maxElevation; level >= minElevation - 1e-9; level -= step) {
- const { continents: count, land } = this._countContinents(elevations, ice, level, threshold);
- // Letting the water fall further would flood the map with land.
- if (land > maxLand) break;
- if (count === target) {
- chosen = level;
- found = true;
- continue;
- }
- // Domes have started merging, so dropping further only loses land.
- if (found && count < target) break;
- if (!found) {
- const difference = Math.abs(count - target);
- if (!fallback || difference < fallback.difference) fallback = { level, difference };
- }
+ const measure = (level) => {
+ const { land } = this._countContinents(elevations, ice, level, threshold);
+ return land - targetLand;
+ };
+ let best = { level: minElevation, difference: measure(minElevation) };
+ if (best.difference <= 0) return best.level;
+ let low = minElevation;
+ let high = maxElevation;
+ const range = Math.max(1e-6, high - low);
+ const iterations = Math.min(32, Math.ceil(Math.log2(range / step)) + 2);
+ for (let i = 0; i < iterations; i += 1) {
+ const level = (low + high) / 2;
+ const difference = measure(level);
+ if (Math.abs(difference) < Math.abs(best.difference)) best = { level, difference };
+ // Too much land means the water is too low: raise the level, and vice
+ // versa.
+ if (difference > 0) low = level;
+ else high = level;
}
- return found ? chosen : fallback ? fallback.level : chosen;
+ return best.level;
}
// How many connected bodies of land at least `threshold` tiles sit above
@@ -388,34 +391,56 @@ export class MapGenerator {
return value - fade * this.config.falloffStrength;
}
- _isPolar(coords, iceNoise = 0) {
- const half = this.config.mapSize.y / 2;
- if (half <= 0) return false;
- const jitter = Math.min(1, Math.max(-1, iceNoise));
- const latitude = this.config.iceLatitude + (this.config.iceRoughness || 0) * jitter;
- return Math.abs(coords.y) / half >= latitude;
+ // How many rows a tile sits from the nearer polar edge: 0 is the outermost
+ // row, growing toward the equator. The map is stored with row 0 at
+ // `-floor(height / 2)`, so this is the one place that maps a tile to its
+ // latitude band.
+ _rowsFromEdge(coords) {
+ const height = this.config.mapSize.y;
+ const row = coords.y + Math.floor(height / 2);
+ return Math.min(row, height - 1 - row);
}
- _chooseTile(coords, elevation, iceNoise) {
+ // The configured ice rows are a cap; on a map far shorter than the reference
+ // a fixed three-row band would bury most of the world, so the bands shrink
+ // with the map and only the full-size game gets all three.
+ iceRowCount() {
+ return Math.min(
+ Math.max(0, this.config.iceRows || 0),
+ Math.max(0, Math.floor(this.config.mapSize.y / 10))
+ );
+ }
+
+ tundraRowCount() {
+ return Math.min(
+ Math.max(0, this.config.tundraRows || 0),
+ Math.max(0, Math.floor(this.config.mapSize.y / 10))
+ );
+ }
+
+ // The polar ice is exactly the `iceRows` rows closest to each edge.
+ _isPolar(coords) {
+ return this._rowsFromEdge(coords) < this.iceRowCount();
+ }
+
+ // The tundra is the `tundraRows` rows just outside the ice, so it never
+ // reaches further equatorward than that.
+ _isTundra(coords) {
+ const rows = this._rowsFromEdge(coords);
+ const ice = this.iceRowCount();
+ return rows >= ice && rows < ice + this.tundraRowCount();
+ }
+
+ _chooseTile(coords, elevation) {
const palette = this._palette;
- if (palette.ice && this._isPolar(coords, iceNoise)) return palette.ice;
+ if (palette.ice && this._isPolar(coords)) return palette.ice;
if (elevation < this.seaLevel) return palette.sea;
// Tundra fringes the ice sheets; land starts as plains otherwise, and the
// terrain clusters are painted over it later.
- if (palette.tundra && this._isTundra(coords, iceNoise)) return palette.tundra;
+ if (palette.tundra && this._isTundra(coords)) return palette.tundra;
return palette.plains;
}
- // The tundra band just outside the ice, its edge wandering on the same noise
- // as the ice so the two meet in a ragged line.
- _isTundra(coords, iceNoise = 0) {
- const half = this.config.mapSize.y / 2;
- if (half <= 0) return false;
- const jitter = Math.min(1, Math.max(-1, iceNoise));
- const latitude = this.config.tundraLatitude + (this.config.iceRoughness || 0) * jitter;
- return Math.abs(coords.y) / half >= latitude;
- }
-
// Third pass: scatter clusters of each biome over the land. Clusters are
// seeded from random land tiles and grown one tile at a time into an
// irregular blob, in priority order (earlier entries win overlaps) and
@@ -501,13 +526,19 @@ export class MapGenerator {
let candidates = this._capitalCandidates(false);
if (candidates.length === 0) candidates = this._capitalCandidates(true);
if (candidates.length === 0) candidates = this.landCells.slice();
+ // Draw the candidates, then work from the best sites down: a strong
+ // preference for coastal plains, with the draw breaking ties, while the
+ // minimum distance still keeps the capitals spread out.
const shuffled = rng.shuffle(candidates.slice());
- for (const coords of shuffled) {
+ const preferred = shuffled
+ .map((coords) => ({ coords, score: this._siteScore(coords) }))
+ .sort((a, b) => b.score - a.score);
+ for (const { coords } of preferred) {
if (chosen.length >= count) break;
if (this._isFarEnough(coords, chosen, this.config.minCapitalDistance)) chosen.push(coords);
}
if (chosen.length < count) {
- for (const coords of shuffled) {
+ for (const { coords } of preferred) {
if (chosen.length >= count) break;
if (!chosen.some((c) => c.x === coords.x && c.y === coords.y)) chosen.push(coords);
}
@@ -559,44 +590,160 @@ export class MapGenerator {
return false;
}
+ // How strongly a tile attracts a city. A coastal plain is ideal: the port
+ // opens the sea lanes and the flat ground feeds the people. Tundra and
+ // desert are shunned, and a landlocked tile is worst of all because it can
+ // never reach the world market.
+ _siteScore(coords) {
+ const tile = this.tiles[key(coords.x, coords.y)];
+ if (!tile) return -Infinity;
+ let score = 0;
+ if (tile.terrainType === "Land") score += 3;
+ else if (tile.terrainType === "Tundra") score -= 5;
+ else if (tile.terrainType === "Desert") score -= 5;
+ else if (tile.terrainType === "Mountain") score -= 3;
+ else if (tile.terrainType === "Hills") score -= 2;
+ else if (tile.terrainType === "Forest") score -= 1;
+ score += this._isCoastal(coords) ? 8 : -10;
+ return score;
+ }
+
pickCities(capitals, citiesPerCiv, minSpacing) {
- const capitalsOnly = () =>
- capitals.map((coords, civIndex) => ({ coords, civIndex, isCapital: true }));
if (capitals.length === 0) return [];
- if (citiesPerCiv <= 1) return capitalsOnly();
- // Lay out neutral cities first, evenly spread and never closer than the
- // minimum distance, then cluster them onto the players so each civ ends up
- // with a compact block of cities instead of scattered exclaves.
- const minDistance = Math.max(minSpacing || 0, CITY_MIN_DISTANCE);
- // Work one landmass at a time: each coast is given as many neutral sites as
- // its capitals need, so the cluster pass can hand every civ its share.
- const groups = this._capitalGroups(capitals);
const cities = [];
- for (const group of groups) {
- group.layout = this._neutralCitySites(
- capitals,
- group.civs,
- group.candidates,
- group.civs.length * citiesPerCiv,
- minDistance
- );
- // Each landmass keeps as many cities per civ as it has room for: a tiny
- // island nation starts with little more than its capital, while the
- // continent nations get their full spread. Doing this per landmass keeps
- // a cramped island from starving the continents, which is why there is no
- // longer one shared count.
- const perCiv = Math.min(citiesPerCiv, Math.floor(group.layout.length / group.civs.length));
- if (perCiv <= 1) {
- for (const civ of group.civs) {
- cities.push({ coords: capitals[civ], civIndex: civ, isCapital: true });
+ if (citiesPerCiv <= 1) {
+ for (let civIndex = 0; civIndex < capitals.length; civIndex++) {
+ cities.push({ coords: capitals[civIndex], civIndex, isCapital: true });
+ }
+ } else {
+ // Lay out neutral cities first, evenly spread and never closer than the
+ // minimum distance, then cluster them onto the players so each civ ends
+ // up with a compact block of cities instead of scattered exclaves.
+ const minDistance = Math.max(minSpacing || 0, CITY_MIN_DISTANCE);
+ // Work one landmass at a time: each coast is given as many neutral sites
+ // as its capitals need, so the cluster pass can hand every civ its share.
+ const groups = this._capitalGroups(capitals);
+ for (const group of groups) {
+ group.layout = this._neutralCitySites(
+ capitals,
+ group.civs,
+ group.candidates,
+ group.civs.length * citiesPerCiv,
+ minDistance
+ );
+ // Each landmass keeps as many cities per civ as it has room for: a
+ // tiny island nation starts with little more than its capital, while
+ // the continent nations get their full spread. Doing this per landmass
+ // keeps a cramped island from starving the continents, which is why
+ // there is no longer one shared count.
+ const perCiv = Math.min(citiesPerCiv, Math.floor(group.layout.length / group.civs.length));
+ if (perCiv <= 1) {
+ for (const civ of group.civs) {
+ cities.push({ coords: capitals[civ], civIndex: civ, isCapital: true });
+ }
+ continue;
}
- continue;
+ this._assignCitySites(capitals, group, perCiv, cities);
}
- this._assignCitySites(capitals, group, perCiv, cities);
}
+ // Whatever the layout did, no land may be left unclaimed and no civ may be
+ // shut out of a landmass it holds by having only inland cities there.
+ this._fillEmptyLandmasses(cities, capitals);
+ this._guaranteeCoastalCities(cities);
return cities;
}
+ // Every landmass must hold at least one city, so no island is left
+ // unclaimed. A landmass the layout pass skipped gets a coastal city (so it
+ // can still reach world trade) belonging to the civilisation whose capital
+ // lies nearest.
+ _fillEmptyLandmasses(cities, capitals) {
+ const occupied = new Set(cities.map((site) => key(site.coords.x, site.coords.y)));
+ for (const component of this._components("Land")) {
+ let settled = false;
+ for (const coords of component) {
+ if (occupied.has(key(coords.x, coords.y))) {
+ settled = true;
+ break;
+ }
+ }
+ if (settled) continue;
+ // Prefer the shore; fall back to the best inland tile for the rare
+ // landmass ringed entirely by ice, so no land is left unclaimed.
+ const site = this._coastalSite(component, occupied, true);
+ if (!site) continue;
+ occupied.add(key(site.x, site.y));
+ cities.push({
+ coords: site,
+ civIndex: this._nearestCapitalCiv(site, capitals),
+ isCapital: false,
+ });
+ }
+ }
+
+ // Every civilisation that holds land on a landmass must hold a coastal city
+ // there, so its ports open that island to the sea. When all of a civ's
+ // cities on a landmass are inland, one coastal site is added for it.
+ _guaranteeCoastalCities(cities) {
+ const components = this._components("Land");
+ const componentOf = new Map();
+ for (let i = 0; i < components.length; i++) {
+ for (const coords of components[i]) componentOf.set(key(coords.x, coords.y), i);
+ }
+ const coastal = new Map();
+ for (const site of cities) {
+ const index = componentOf.get(key(site.coords.x, site.coords.y));
+ if (index === undefined) continue;
+ const group = `${index}:${site.civIndex}`;
+ if (this._isCoastal(site.coords)) coastal.set(group, true);
+ else if (!coastal.has(group)) coastal.set(group, false);
+ }
+ const occupied = new Set(cities.map((site) => key(site.coords.x, site.coords.y)));
+ for (const [group, hasCoast] of coastal) {
+ if (hasCoast) continue;
+ const [index, civIndex] = group.split(":").map(Number);
+ const site = this._coastalSite(components[index], occupied);
+ if (!site) continue;
+ occupied.add(key(site.x, site.y));
+ cities.push({ coords: site, civIndex, isCapital: false });
+ }
+ }
+
+ // The best tile of `component` that no city holds yet: the highest
+ // `_siteScore`, so a coastal plain if there is one, ranked by `_siteScore`.
+ // With `allowInland` an inland tile may win too, for the rare landmass with
+ // no sea of its own.
+ _coastalSite(component, occupied, allowInland = false) {
+ let best = null;
+ let bestScore = -Infinity;
+ for (const coords of component) {
+ if (!allowInland && !this._isCoastal(coords)) continue;
+ const k = key(coords.x, coords.y);
+ if (occupied && occupied.has(k)) continue;
+ const score = this._siteScore(coords);
+ if (score > bestScore) {
+ bestScore = score;
+ best = coords;
+ }
+ }
+ return best ? { x: best.x, y: best.y } : null;
+ }
+
+ // The civilisation whose capital is nearest `coords`, used to hand an empty
+ // island to somebody when no capital sits on it.
+ _nearestCapitalCiv(coords, capitals) {
+ let civ = 0;
+ let best = Infinity;
+ for (let i = 0; i < capitals.length; i++) {
+ const distance = this.topology.tileDistance(coords, capitals[i]);
+ if (distance < best) {
+ best = distance;
+ civ = i;
+ }
+ }
+ return civ;
+ }
+
// Groups the capitals by the connected landmass they stand on, with that
// landmass's tiles as the pool cities may be drawn from, so no city ends up
// stranded on an island no capital can reach.
@@ -614,21 +761,34 @@ export class MapGenerator {
return groups.filter((group) => group.civs.length > 0);
}
- // Neutral layout: the capitals plus the land tile furthest from every city so
- // far, until there is one site per slot. Farthest-point sampling keeps them
- // evenly spread and at least `minDistance` apart.
+ // Neutral layout: the capitals plus the land tiles that best combine spacing
+ // and site quality, until there is one site per slot. A tile's priority is
+ // its distance to the nearest chosen site plus a bonus for being a good city
+ // site (a coastal plain), so the layout spreads out while still leaning to
+ // the coast. Sites are never closer than `minDistance` to another.
_neutralCitySites(capitals, civs, candidates, count, minDistance) {
const sites = civs.map((civ) => ({ coords: capitals[civ], civIndex: civ, isCapital: true }));
if (sites.length >= count) return sites;
const centers = civs.map((civ) => capitals[civ]);
const distance = candidates.map((coords) => this._nearestDistance(coords, centers));
+ const scores = candidates.map((coords) => this._siteScore(coords));
+ let minScore = Infinity;
+ let maxScore = -Infinity;
+ for (const score of scores) {
+ if (score < minScore) minScore = score;
+ if (score > maxScore) maxScore = score;
+ }
+ const span = maxScore - minScore;
+ const preference = scores.map((score) => (span > 1e-6 ? (score - minScore) / span : 0));
+ const bias = minDistance;
while (sites.length < count) {
let best = -1;
- let bestDistance = -1;
+ let bestPriority = -Infinity;
for (let i = 0; i < candidates.length; i++) {
if (distance[i] < minDistance) continue;
- if (distance[i] > bestDistance) {
- bestDistance = distance[i];
+ const priority = distance[i] + bias * preference[i];
+ if (priority > bestPriority) {
+ bestPriority = priority;
best = i;
}
}
diff --git a/tests/air_test.js b/tests/air_test.js
index 66f8229..dcc641b 100644
--- a/tests/air_test.js
+++ b/tests/air_test.js
@@ -1,5 +1,5 @@
import { TestCase } from "./framework/test_case.js";
-import { smallState } from "./framework/helpers.js";
+import { smallState, settleInlandCity } from "./framework/helpers.js";
import { parseKey, key } from "../shared/hex.js";
import { RESOURCE_RULES } from "../shared/data.js";
@@ -22,7 +22,7 @@ function coastalCity(state, civ) {
}
function inlandCity(state, civ) {
- return state.cities.find((city) => city.civ === civ && !state.isCoastalCity(city)) || null;
+ return settleInlandCity(state, civ);
}
function giveBuilding(state, city, id, level = 1) {
diff --git a/tests/currency_test.js b/tests/currency_test.js
new file mode 100644
index 0000000..968a42a
--- /dev/null
+++ b/tests/currency_test.js
@@ -0,0 +1,68 @@
+import { TestCase } from "./framework/test_case.js";
+import {
+ setCurrency,
+ setCurrencyBook,
+ currencySymbol,
+ currencyFor,
+ rateToNational,
+ viewerCurrencyCiv,
+ moneyText,
+} from "../client/js/currency.js";
+
+export class CurrencyTest extends TestCase {
+ test_the_symbol_and_book_come_from_the_snapshot() {
+ setCurrency({ code: "EUR", symbol: "€", name: "Euro" });
+ this.assertEqual(currencySymbol(), "€");
+ setCurrencyBook(
+ [
+ { civ: 0, code: "EUR", symbol: "€", name: "Euro", value: 1 },
+ { civ: 1, code: "FRF", symbol: "₣", name: "Franc", value: 2 },
+ ],
+ [
+ { civ: 0, code: "EUR", symbol: "€", name: "Euro", rate: 1 },
+ { civ: 1, code: "FRF", symbol: "₣", name: "Franc", rate: 0.5 },
+ ],
+ 0
+ );
+ this.assertEqual(viewerCurrencyCiv(), 0);
+ this.assertEqual(currencyFor(1).symbol, "₣");
+ this.assertEqual(rateToNational(0), 1, "our own currency is a rate of one");
+ this.assertEqual(rateToNational(1), 0.5, "their money buys at the snapshot rate");
+ }
+
+ test_the_rate_falls_back_to_the_currency_values() {
+ setCurrencyBook(
+ [
+ { civ: 0, symbol: "€", value: 3 },
+ { civ: 1, symbol: "₣", value: 2 },
+ ],
+ [],
+ 0
+ );
+ this.assertEqual(rateToNational(1), 1.5, "3/2 units of theirs per one of ours");
+ }
+
+ test_an_unknown_nation_is_not_converted() {
+ setCurrencyBook([], [], -1);
+ this.assertEqual(rateToNational(3), 1);
+ this.assertEqual(currencyFor(3), null);
+ }
+
+ test_money_text_quotes_a_foreign_figure_beside_ours() {
+ setCurrency({ symbol: "€" });
+ setCurrencyBook(
+ [
+ { civ: 0, symbol: "€", value: 1 },
+ { civ: 1, symbol: "₣", value: 1 },
+ ],
+ [
+ { civ: 0, rate: 1 },
+ { civ: 1, rate: 2 },
+ ],
+ 0
+ );
+ const plain = (v) => String(v);
+ this.assertEqual(moneyText(3, 1, plain), "₣6 (€3)", "their money first, ours beside it");
+ this.assertEqual(moneyText(3, 0, plain), "€3", "our own figure carries no conversion");
+ }
+}
diff --git a/tests/food_synthesis_test.js b/tests/food_synthesis_test.js
index 210d7da..c14d641 100644
--- a/tests/food_synthesis_test.js
+++ b/tests/food_synthesis_test.js
@@ -86,6 +86,12 @@ export class FoodSynthesisModelTest extends TestCase {
test_a_region_short_of_food_forces_extra_from_energy() {
const state = smallState();
const city = cityOf(state, 0);
+ // The generated region may open self-sufficient; strip its farmland so the
+ // fixture is genuinely short, which is the point of the test.
+ for (const coords of state.regionTiles(city)) {
+ if (coords.x === city.coords.x && coords.y === city.coords.y) continue;
+ state.tilePopulation.set(key(coords.x, coords.y), 0);
+ }
const shortfall = foodNeedPerDay(state.getCityEconomy(city).population) -
regionFood(state, city);
this.assertGreater(shortfall, 0, "the fixture region opens short of food");
diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js
index 9e3c0b1..557bad7 100644
--- a/tests/framework/helpers.js
+++ b/tests/framework/helpers.js
@@ -40,6 +40,9 @@ export function smallConfig(overrides = {}) {
// The fixtures stay one continent: splitting them changes where capitals
// and cities fall, which the tests pin down.
minContinents: 1,
+ // The tiny fixture has no room to run the chip foundries' grids, so its
+ // nations open without one; tests that want them opt back in.
+ startingChipFabs: 0,
...overrides,
};
}
@@ -95,6 +98,26 @@ export function cityOf(state, civ) {
return state.cities.find((city) => city.civ === civ) || null;
}
+// A city on an inland tile, for the tests that need one to check the coastal
+// placement rules. The generator very strongly prefers the coast, so a small
+// fixture may well have none; this settles a spare inland tile if it has to.
+export function settleInlandCity(state, civ = 0, accept = null) {
+ const existing = state.cities.find(
+ (city) => city.civ === civ && !state.isCoastalCity(city) && (!accept || accept(city.coords))
+ );
+ if (existing) return existing;
+ for (const coords of state.landCells) {
+ if (state.cityAt(coords) || state.unitAt(coords)) continue;
+ if (state.isCoastalCoords(coords)) continue;
+ if (accept && !accept(coords)) continue;
+ const city = state._spawnCity({ civIndex: civ, coords, isCapital: false });
+ state._rebuildCityIndex();
+ state._recomputeTerritory();
+ return city;
+ }
+ return null;
+}
+
// Gives a city a level of a building directly, so a test that trains a unit
// gated behind a building does not have to walk the construction queue first.
export function grantBuilding(state, city, id, level = 1) {
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index 1a6ab04..7a39c4e 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -7,7 +7,7 @@ import { compact } from "../shared/text_format.js";
import { tileImprovementResourceCost } from "../shared/resources.js";
import { quotedBuildCost, marketPricesFrom } from "../client/js/modals/format.js";
import { parseKey } from "../shared/hex.js";
-import { smallState, adjacentLand, grantBuilding } from "./framework/helpers.js";
+import { smallState, adjacentLand, grantBuilding, stripPorts } from "./framework/helpers.js";
function stubNetwork() {
return {
@@ -75,6 +75,7 @@ export class GameScreenTest extends TestCase {
this.assertTrue(env.$("#screen-game").hasClass("active"));
const state = smallState();
+ stripPorts(state, 0);
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
grantBuilding(state, capital, "barracks");
screen.onState(state.snapshot(0));
@@ -538,6 +539,7 @@ export class GameScreenTest extends TestCase {
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = smallState();
+ stripPorts(state, 0);
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
grantBuilding(state, capital, "barracks");
screen.onState(state.snapshot(0));
@@ -570,6 +572,7 @@ export class GameScreenTest extends TestCase {
screen.enter();
const state = smallState();
state.testing = true;
+ stripPorts(state, 0);
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
grantBuilding(state, capital, "barracks");
screen.onState(state.snapshot(0));
diff --git a/tests/inflation_test.js b/tests/inflation_test.js
new file mode 100644
index 0000000..7ca6fb0
--- /dev/null
+++ b/tests/inflation_test.js
@@ -0,0 +1,56 @@
+import { TestCase } from "./framework/test_case.js";
+import { smallState } from "./framework/helpers.js";
+import { RESOURCE_IDS, RESOURCE_MARKET_BASE } from "../shared/data.js";
+
+export class InflationHistoryTest extends TestCase {
+ test_the_index_starts_at_one_hundred() {
+ const state = smallState();
+ for (const id of RESOURCE_IDS) {
+ const info = state.resourceInflation(id);
+ this.assertApprox(info.index, 100, 1e-9, `${id} index`);
+ this.assertApprox(info.monthly, 0, 1e-9, `${id} monthly`);
+ this.assertApprox(info.yearly, 0, 1e-9, `${id} yearly`);
+ }
+ }
+
+ test_monthly_inflation_compares_with_last_month() {
+ const state = smallState();
+ const base = RESOURCE_MARKET_BASE.steel;
+ state.setResourcePrice("steel", base * 1.1);
+ state._sampleResourcePrices();
+ const info = state.resourceInflation("steel");
+ this.assertApprox(info.index, 110, 1e-6, "the index tracks the price");
+ this.assertApprox(info.monthly, 0.1, 1e-9, "a tenth dearer than last month");
+ this.assertApprox(info.yearly, 0.1, 1e-9, "and since the start, through 2000");
+ }
+
+ test_yearly_inflation_uses_the_same_month_a_year_earlier() {
+ const state = smallState();
+ const base = RESOURCE_MARKET_BASE.food;
+ // Twelve monthly openings, each 1% dearer than the last.
+ for (let month = 1; month <= 12; month++) {
+ state.setResourcePrice("food", base * (1 + 0.01 * month));
+ state._sampleResourcePrices();
+ }
+ // Then the live price a further step up.
+ state.setResourcePrice("food", base * 1.13);
+ const info = state.resourceInflation("food");
+ // The year-ago sample is January 2000 (the base); last month's opening is
+ // the eleventh sample.
+ this.assertApprox(info.yearly, 0.13, 1e-9, "growth since the same month last year");
+ this.assertApprox(info.monthly, 1.13 / 1.11 - 1, 1e-9, "growth since last month");
+ }
+
+ test_the_snapshot_ships_the_series_and_inflation() {
+ const state = smallState();
+ const shared = state.serializeShared();
+ this.assertSize(shared.resourcePriceHistory, RESOURCE_IDS.length);
+ this.assertSize(shared.resourceInflation, RESOURCE_IDS.length);
+ for (const [id, prices] of shared.resourcePriceHistory) {
+ this.assertTrue(RESOURCE_IDS.includes(id));
+ this.assertSize(prices, 1, "one opening price at the start");
+ }
+ const steel = shared.resourceInflation.find((entry) => entry.id === "steel");
+ this.assertApprox(steel.index, 100, 1e-9);
+ }
+}
diff --git a/tests/map_generator_test.js b/tests/map_generator_test.js
index 590447f..4e11a03 100644
--- a/tests/map_generator_test.js
+++ b/tests/map_generator_test.js
@@ -149,7 +149,9 @@ export class MapGeneratorTest extends TestCase {
}
test_pick_cities_gives_every_civilisation_the_same_count() {
- const cfg = config({ citiesPerCiv: 3, citySpacing: 5 });
+ // No speckle: a lone islet would rightly be settled on its own, which would
+ // skew the shared count this test is about.
+ const cfg = config({ citiesPerCiv: 3, citySpacing: 5, minIslandFraction: 0.02, minLakeFraction: 0.02 });
const generator = generate(cfg);
const capitals = generator.pickCapitals(3, new Random(1));
this.assertSize(capitals, 3);
@@ -176,26 +178,23 @@ export class MapGeneratorTest extends TestCase {
this.assertEqual(capitalSites, capitals.length);
}
- test_poles_are_ice() {
- const cfg = config({ iceLatitude: 0.5 });
+ // Ice only ever lies in the `iceRows` rows closest to each edge, so the
+ // sheets never wander more than a few rows toward the equator.
+ test_ice_is_only_the_outermost_rows() {
+ const cfg = config({ iceRows: 3 });
const generator = generate(cfg);
- const half = cfg.mapSize.y / 2;
- const core = cfg.iceLatitude + (cfg.iceRoughness || 0);
+ const height = cfg.mapSize.y;
+ const originY = -Math.floor(height / 2);
+ let ice = 0;
for (const k in generator.tiles) {
- const i = k.indexOf(",");
- const y = Number(k.slice(i + 1));
- if (Math.abs(y) / half >= core) {
- this.assertEqual(generator.tiles[k].terrainType, "Ice", `tile ${k}`);
- }
+ if (generator.tiles[k].terrainType !== "Ice") continue;
+ ice += 1;
+ const y = Number(k.slice(k.indexOf(",") + 1));
+ const row = y - originY;
+ const edge = Math.min(row, height - 1 - row);
+ this.assertLess(edge, generator.iceRowCount(), `ice at ${k} is beyond the outermost rows`);
}
- }
-
- test_ice_edge_wanders_with_noise() {
- const cfg = config({ iceLatitude: 0.5, iceRoughness: 0.2 });
- const generator = generate(cfg);
- const coords = { x: 0, y: 6 };
- this.assertTrue(generator._isPolar(coords, -1), "the edge can reach south");
- this.assertFalse(generator._isPolar(coords, 1), "the edge can pull north");
+ this.assertGreater(ice, 0, "the poles are frozen");
}
test_equator_is_not_ice() {
@@ -207,17 +206,24 @@ export class MapGeneratorTest extends TestCase {
}
}
- test_tundra_fringes_the_poles() {
- const cfg = config();
+ // Tundra only fringes the ice: it lies within `tundraRows` rows of the ice
+ // sheets' furthest row and never reaches further equatorward.
+ test_tundra_stays_within_three_rows_of_the_ice() {
+ const cfg = config({ iceRows: 3, tundraRows: 3 });
const generator = generate(cfg);
- const half = cfg.mapSize.y / 2;
- const inner = cfg.tundraLatitude - (cfg.iceRoughness || 0);
+ const height = cfg.mapSize.y;
+ const originY = -Math.floor(height / 2);
+ const ice = generator.iceRowCount();
+ const band = ice + generator.tundraRowCount();
let tundra = 0;
for (const k in generator.tiles) {
if (generator.tiles[k].terrainType !== "Tundra") continue;
tundra += 1;
const y = Number(k.slice(k.indexOf(",") + 1));
- this.assertGreaterOrEqual(Math.abs(y) / half, inner, `tundra at ${k} is polar`);
+ const row = y - originY;
+ const edge = Math.min(row, height - 1 - row);
+ this.assertGreaterOrEqual(edge, ice, `tundra at ${k} is not beyond the ice`);
+ this.assertLess(edge, band, `tundra at ${k} is too far from the ice`);
}
this.assertGreater(tundra, 0, "there is tundra near the poles");
}
@@ -300,25 +306,62 @@ export class MapGeneratorTest extends TestCase {
);
}
- // The seeded domes plus the water search should give exactly the requested
- // number of continents on every seed, and none of them may dwarf the others.
- test_seeded_domes_leave_exactly_the_requested_continents() {
+ // The water search has to leave the land at the requested share of the
+ // ice-free tiles, on the real map, for every seed.
+ test_land_covers_sixty_percent_of_the_ice_free_tiles() {
const seeds = [0, 1, 2, 3, 7, 42, 12345, 999, 2024, 5];
for (const seed of seeds) {
// The real map size, so the test covers the configuration the game uses.
- const cfg = config({ minContinents: 3, terrainSeed: seed, mapSize: { x: 100, y: 100 } });
+ const cfg = config({ terrainSeed: seed, mapSize: { x: 100, y: 100 } });
const generator = generate(cfg);
- // A continent is a body of at least the configured fraction of the map;
- // the small islands the detail noise throws off do not count.
- const min = Math.round(cfg.mapSize.x * cfg.mapSize.y * cfg.minContinentFraction);
- const continents = componentSizes(generator, "Land").filter((size) => size >= min);
- this.assertEqual(continents.length, 3, `seed ${seed} has three continents`);
- const total = continents.reduce((sum, size) => sum + size, 0);
- this.assertLess(
- Math.max(...continents),
- total * 0.6,
- `seed ${seed} has no single dominant continent`
- );
+ let land = 0;
+ let iceFree = 0;
+ for (const k in generator.tiles) {
+ const tile = generator.tiles[k];
+ if (tile.terrainClass === "Ice") continue;
+ iceFree += 1;
+ if (tile.terrainClass === "Land") land += 1;
+ }
+ this.assertApprox(land / iceFree, cfg.landFraction, 0.02, `seed ${seed} land fraction`);
+ }
+ }
+
+ // No landmass may be left without a city, so every island is settled.
+ test_every_landmass_is_settled() {
+ const cfg = config({ terrainSeed: 12345, mapSize: { x: 100, y: 100 }, nationCount: 6 });
+ const generator = generate(cfg);
+ const capitals = generator.pickCapitals(6, new Random(1));
+ const sites = generator.pickCities(capitals, cfg.citiesPerCiv, cfg.citySpacing);
+ const index = landmassIndex(generator);
+ const settled = new Set();
+ for (const site of sites) {
+ const id = index.get(key(site.coords.x, site.coords.y));
+ if (id !== undefined) settled.add(id);
+ }
+ const landmasses = new Set(index.values());
+ for (const id of landmasses) {
+ this.assertTrue(settled.has(id), `landmass ${id} has a city`);
+ }
+ }
+
+ // Every civilisation that holds a landmass must hold a coastal city there, so
+ // every island it owns has a port to the sea.
+ test_every_civilisation_gets_a_coastal_city_per_landmass() {
+ const cfg = config({ terrainSeed: 12345, mapSize: { x: 100, y: 100 }, nationCount: 6 });
+ const generator = generate(cfg);
+ const capitals = generator.pickCapitals(6, new Random(1));
+ const sites = generator.pickCities(capitals, cfg.citiesPerCiv, cfg.citySpacing);
+ const index = landmassIndex(generator);
+ const held = new Map();
+ for (const site of sites) {
+ const id = index.get(key(site.coords.x, site.coords.y));
+ const group = `${id}:${site.civIndex}`;
+ const coastal = generator._isCoastal(site.coords);
+ if (coastal) held.set(group, true);
+ else if (!held.has(group)) held.set(group, false);
+ }
+ for (const [group, coastal] of held) {
+ this.assertTrue(coastal, `${group} has a coastal city`);
}
}
diff --git a/tests/map_modes_test.js b/tests/map_modes_test.js
index fb496db..d5a44a8 100644
--- a/tests/map_modes_test.js
+++ b/tests/map_modes_test.js
@@ -151,7 +151,7 @@ export class MapModesTest extends TestCase {
this.assertEmpty(env.$("#layer-resources .resource-node"), "hidden on the terrain map");
this.assertEmpty(env.$("#layer-resources .border-seg"), "and no delivery legs either");
map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
- map.setMapMode("population");
+ map.setMapMode("gdp");
this.assertNotEmpty(env.$("#layer-resources .resource-node"), "shown in an economic mode");
this.assertNotEmpty(env.$("#layer-resources .border-seg"), "the delivery leg is drawn");
} finally {
@@ -159,13 +159,47 @@ export class MapModesTest extends TestCase {
}
}
+ async test_the_migration_graph_shows_in_population_mode() {
+ const state = smallState();
+ const env = await setupDom();
+ const map = this.buildMap(env);
+ try {
+ map.applySnapshot(state.snapshot(0));
+ map.setMapMode("population");
+ map.setMigrationGraph({ links: [[0, 0, 3, 3, 120], [3, 3, 0, 0, 40]] });
+ this.assertSize(
+ env.$("#layer-resources .migration-arrow-head"), 2,
+ "one arrowhead per migration flow"
+ );
+ this.assertEmpty(
+ env.$("#layer-resources .resource-node"), "the delivery graph is not drawn"
+ );
+ this.assertEqual(
+ env.$("#map-legend-graph-title").text(), "Migration (last month)",
+ "the key is titled for the migration"
+ );
+ const labels = env.$("#map-legend-resources .map-legend-resource")
+ .map((_, el) => env.$(el).text()).get();
+ this.assertTrue(labels.some((t) => t.includes("People between cities")), "the flow is keyed");
+
+ // Hidden off the economic modes, and off the other economic mode.
+ map.setMapMode("terrain");
+ this.assertEmpty(env.$("#layer-resources .migration-arrow-head"), "hidden on terrain");
+ map.setMapMode("gdp");
+ map.setMigrationGraph({ links: [[0, 0, 3, 3, 120]] });
+ this.assertEmpty(env.$("#layer-resources .migration-arrow-head"), "not on the GDP map");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_the_delivery_graph_has_a_colour_legend() {
const state = smallState();
const env = await setupDom();
const map = this.buildMap(env);
try {
map.applySnapshot(state.snapshot(0));
- map.setMapMode("population");
+ map.setMapMode("gdp");
map.setResourceGraph({
nodes: [[1, 1, 1]],
links: [[0, 0, 1, 1, "steel", 10], [2, 2, 3, 3, "food", 5]],
@@ -173,6 +207,7 @@ export class MapModesTest extends TestCase {
this.assertFalse(
env.$("#map-legend-graph").hasClass("hidden"), "the trade-route key is shown"
);
+ this.assertEqual(env.$("#map-legend-graph-title").text(), "Trade routes", "titled for trade");
const labels = env.$("#map-legend-resources .map-legend-resource")
.map((_, el) => env.$(el).text()).get();
this.assertTrue(labels.some((t) => t.includes("Steel")), "steel is keyed");
diff --git a/tests/map_view_test.js b/tests/map_view_test.js
index d1c81bb..029b612 100644
--- a/tests/map_view_test.js
+++ b/tests/map_view_test.js
@@ -4,7 +4,7 @@ import { MapView, inwardOffset } from "../client/js/map_view.js";
import { mapToLocal, COL_STEP, HEX_W, parseKey } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
import { CAMERA_TILT, HEIGHT_LIFT } from "../client/js/map_view/constants.js";
-import { defaultState, smallState, mapLayers, adjacentLand, seaTile, grantBuilding } from "./framework/helpers.js";
+import { defaultState, smallState, mapLayers, adjacentLand, seaTile, grantBuilding, stripPorts } from "./framework/helpers.js";
export class MapViewTest extends TestCase {
async build(state) {
@@ -878,6 +878,7 @@ export class MapViewTest extends TestCase {
async test_each_building_level_adds_a_square_around_the_city() {
const state = smallState();
+ stripPorts(state, 0);
const city = state.cities.find((c) => c.civ === 0);
city.buildings[0] = 2;
city.buildings[1] = 1;
diff --git a/tests/migration_test.js b/tests/migration_test.js
index b401f75..9a651f6 100644
--- a/tests/migration_test.js
+++ b/tests/migration_test.js
@@ -106,4 +106,42 @@ export class MigrationTest extends TestCase {
// 1000 people per airport level a day, and the route's level is 1.
this.assertTrue(lost <= 1_001, `the flight carried at most its capacity, lost ${lost}`);
}
+
+ test_the_migration_graph_tallies_city_to_city_flows() {
+ const state = smallState();
+ const from = cityOf(state, 0);
+ const to = state.cities.find((city) => city.id !== from.id);
+ this.assertNotNull(from, "the fixture has an origin");
+ this.assertNotNull(to, "and a destination");
+ const moved = state._movePopulation(from, to, 100_000, true);
+ this.assertGreater(moved, 0, "people moved");
+ const graph = state.snapshot(0).migrationGraph;
+ this.assertHas(graph, "links");
+ const link = graph.links.find(
+ (entry) =>
+ entry[0] === from.coords.x && entry[1] === from.coords.y &&
+ entry[2] === to.coords.x && entry[3] === to.coords.y
+ );
+ this.assertNotNull(link, "the city-to-city flow was tallied");
+ this.assertApprox(link[4], moved, 1, "the link carries everyone who moved");
+ }
+
+ test_the_migration_graph_rolls_over_a_month() {
+ const state = smallState();
+ const from = cityOf(state, 0);
+ const to = state.cities.find((city) => city.id !== from.id);
+ state._movePopulation(from, to, 100_000, true);
+ this.assertNotEmpty(state.snapshot(0).migrationGraph.links, "the flow is on the graph");
+ // Each day archives the last, so a month's worth of empties eventually
+ // pushes the only flow out of the rolling window.
+ for (let day = 0; day < 31; day++) state._tickMigration();
+ this.assertEmpty(state.snapshot(0).migrationGraph.links, "the month-old flow has fallen off");
+ }
+
+ test_internal_equalisation_does_not_pollute_the_migration_graph() {
+ // The start-of-game levelling moved people with recording off, so the
+ // population map opens with no migration arrows.
+ const state = smallState();
+ this.assertEmpty(state.snapshot(0).migrationGraph.links, "the opening graph is empty");
+ }
}
diff --git a/tests/modals_test.js b/tests/modals_test.js
index bc824b2..9d1d66f 100644
--- a/tests/modals_test.js
+++ b/tests/modals_test.js
@@ -4,6 +4,7 @@ import { CityModal, NationModal, ConfirmModal, NewsModal } from "../client/js/mo
import { buildingEffectText, describeNews } from "../client/js/modals/format.js";
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, technologyCost } from "../shared/data.js";
import { groupDigits } from "../shared/text_format.js";
+import { setCurrency, setCurrencyBook } from "../client/js/currency.js";
export class CityModalTest extends TestCase {
test_utility_buildings_describe_their_mechanic() {
@@ -779,6 +780,54 @@ export class NationModalTest extends TestCase {
}
}
+ async test_diplomacy_shows_a_foreign_figure_in_its_own_currency_with_ours_beside_it() {
+ const env = await setupDom();
+ try {
+ setCurrency({ code: "EUR", symbol: "€", name: "Euro" });
+ setCurrencyBook(
+ [
+ { civ: 0, symbol: "€", name: "Euro", value: 1 },
+ { civ: 1, symbol: "₣", name: "Franc", value: 1 },
+ ],
+ [
+ { civ: 0, symbol: "€", rate: 1 },
+ { civ: 1, symbol: "₣", rate: 2 },
+ ],
+ 0
+ );
+ const modal = new NationModal();
+ const civs = [{ name: "France" }, { name: "Britain" }];
+ const stats = [
+ { population: 1.0e6, gdp: 1.0e9, gdpPerCapita: 1000 },
+ { population: 1.5e6, gdp: 3.0e9, gdpPerCapita: 2000 },
+ ];
+ modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, {
+ civs,
+ localCiv: 0,
+ relations: [],
+ stats,
+ });
+ env.$("#modal-nation .tab[data-tab=diplomacy]").click();
+
+ const britain = env.$("#diplomacy-list tbody tr").eq(0);
+ const gdp = britain.find("td.numeric").eq(0);
+ this.assertTrue(gdp.text().includes("₣6B"), "the foreign GDP reads in its own currency");
+ this.assertTrue(
+ gdp.find(".conversion").text().includes("€3B"),
+ "our equivalent sits beside it in parentheses"
+ );
+
+ const france = env.$("#diplomacy-list tbody tr").eq(1);
+ const own = france.find("td.numeric").eq(0);
+ this.assertTrue(own.text().includes("€1B"), "our own GDP stays in our money");
+ this.assertSize(own.find(".conversion"), 0, "our own figure needs no conversion");
+ } finally {
+ setCurrencyBook([], [], -1);
+ setCurrency({ symbol: "¤" });
+ teardownDom(env);
+ }
+ }
+
async test_diplomacy_table_sorts_by_every_column() {
const env = await setupDom();
try {
@@ -940,6 +989,72 @@ export class NationModalTest extends TestCase {
teardownDom(env);
}
}
+
+ async test_central_bank_charts_commodity_prices() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ const money = {
+ viewerCiv: 0,
+ currency: { code: "FRF", symbol: "₣", name: "French Franc" },
+ currencies: [{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", value: 1 }],
+ exchangeRates: [{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", rate: 1 }],
+ centralBank: {
+ rate: 0.02,
+ currency: { code: "FRF", symbol: "₣", name: "French Franc" },
+ reserves: [],
+ loans: 0,
+ },
+ taxes: {
+ sales: { rate: 0.2, resources: {} },
+ export: { rate: 0.05, resources: {} },
+ import: { rate: 0.05, resources: {} },
+ },
+ takeTax: { sales: 0, export: 0, import: 0 },
+ commodityInflation: 0,
+ currencyHoldings: null,
+ privateCash: 0,
+ cities: [],
+ resourcePriceHistory: [
+ ["energy", [100, 110, 120]],
+ ["food", [100, 95, 105]],
+ ],
+ resourceInflation: [
+ { id: "energy", index: 120, monthly: 0.09, yearly: 0.2 },
+ { id: "food", index: 105, monthly: 0.105, yearly: 0.05 },
+ ],
+ };
+ const resourcesInfo = {
+ summary: {
+ stock: {}, need: {}, production: {}, prices: {},
+ consumed: { steel: 0, food: 0, luxury: 0, hightech: 0, energy: 0 },
+ external: { buy: {}, sell: {} },
+ },
+ market: [],
+ trade: [],
+ };
+ modal.show(
+ GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6,
+ { months: [], cities: [] }, null, [], null, null, resourcesInfo, money
+ );
+ env.$("#modal-nation .tab[data-tab=economy]").click();
+ env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
+ this.assertSize(env.$("#economy-central-bank .line-chart"), 1, "the index chart is drawn");
+ this.assertSize(
+ env.$("#economy-central-bank .line-chart-line"), 2, "one line per commodity"
+ );
+ const table = env.$("#economy-central-bank .inflation-table").text();
+ this.assertTrue(table.includes("Energy"), "energy is tabled");
+ this.assertTrue(table.includes("Food"), "food is tabled");
+ this.assertTrue(table.includes("+9.00%"), "monthly inflation is shown");
+ this.assertTrue(table.includes("+20.00%"), "yearly inflation is shown");
+ this.assertTrue(
+ env.$("#economy-sub").text().includes("Commodity inflation"), "the headline is shown"
+ );
+ } finally {
+ teardownDom(env);
+ }
+ }
}
export class ResourceTrendTest extends TestCase {
diff --git a/tests/resources_test.js b/tests/resources_test.js
index 7155215..b603e55 100644
--- a/tests/resources_test.js
+++ b/tests/resources_test.js
@@ -1,5 +1,5 @@
import { TestCase } from "./framework/test_case.js";
-import { smallState, cityOf, grantBuilding } from "./framework/helpers.js";
+import { smallState, defaultState, cityOf, grantBuilding, settleInlandCity, stripPorts } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import {
RESOURCE_RULES,
@@ -214,12 +214,16 @@ export class ResourceModelTest extends TestCase {
test_region_resource_info_reports_reserves_value_and_consumption() {
const state = smallState();
+ // The consumption compared here is the people's need alone; a seeded port
+ // would add its own upkeep to the region's steel and energy draw.
+ stripPorts(state);
const city = cityOf(state, 0);
const store = state.getCityResourceStock(city);
store.food = 1000;
store.steel = 2000;
store.hightech = 50;
store.luxury = 10;
+ store.energy = 0;
const info = state.getRegionResourceInfo(city);
this.assertEqual(info.stock.food, 1000);
this.assertEqual(info.stock.steel, 2000);
@@ -598,6 +602,52 @@ export class ResourceModelTest extends TestCase {
this.assertNotEqual(describe(first), describe(other), "a different seed lays out different works");
}
+ test_two_nations_open_with_a_chip_foundry_apart() {
+ const state = smallState(["france", "britain", "poland"], 12345, { startingChipFabs: 2 });
+ const fabs = [];
+ for (const [k, id] of state.tileImprovements) {
+ if (id !== "chip_foundry") continue;
+ fabs.push({ k, owner: state.tileImprovementOwner.get(k) });
+ }
+ this.assertSize(fabs, 2, "the world opens with exactly two chip foundries");
+ this.assertNotEqual(fabs[0].owner, fabs[1].owner, "the two foundries are in different nations");
+ for (const fab of fabs) {
+ this.assertTrue(
+ state.roads.has(fab.k) || state.railways.has(fab.k),
+ "a seeded foundry sits on the transport network"
+ );
+ }
+ // The pair is drawn from the world seed, so a replay lays them out again.
+ const again = smallState(["france", "britain", "poland"], 12345, { startingChipFabs: 2 });
+ const describe = (s) =>
+ Array.from(s.tileImprovements.entries())
+ .filter(([, id]) => id === "chip_foundry")
+ .map(([k]) => k)
+ .sort()
+ .join("|");
+ this.assertEqual(describe(state), describe(again), "the foundries are deterministic from the seed");
+ }
+
+ test_the_chip_foundries_are_powered_on_a_full_map() {
+ // A full roster: with only two nations the tiny starting islands can leave
+ // one nation without the land a foundry's grid needs.
+ const state = defaultState(["france", "britain", "germany", "poland"]);
+ const fabs = [];
+ for (const [k, id] of state.tileImprovements) {
+ if (id === "chip_foundry") fabs.push(state.tileImprovementOwner.get(k));
+ }
+ this.assertSize(fabs, 2, "a full map opens with two chip foundries");
+ this.assertNotEqual(fabs[0], fabs[1], "the two foundries are in different nations");
+ // Their huge draw is settled like any other converter's, so every grid opens
+ // with the power to run them.
+ const components = state._resourceLandComponents();
+ const foodEnergy = state._foodEnergyByComponent(components);
+ for (let civ = 0; civ < state.civilisations.length; civ++) {
+ const energy = state._resourceSeedEnergy(civ, components, foodEnergy);
+ this.assertEmpty(energy, "the grid opens with the power the foundry draws");
+ }
+ }
+
// ------------------------------------------------------- upkeep -------
test_building_upkeep_is_materials_per_level_per_day() {
@@ -792,7 +842,13 @@ export class ResourceModelTest extends TestCase {
test_an_encircled_city_cannot_buy_from_the_market() {
const state = smallState();
state.budgets.set(0, 1e15);
- const city = cityOf(state, 0);
+ // A coastal city would keep a sea lane open, so besiege an inland one and
+ // ring every land neighbour with enemies.
+ const city = settleInlandCity(state, 0, (coords) => {
+ const land = state.topology.neighbours(coords.x, coords.y).filter((n) => state._isLand(n));
+ return land.length > 0 && !land.some((n) => state.unitAt(n) || state.cityAt(n));
+ });
+ this.assertNotNull(city, "the fixture has an inland city to encircle");
// Ring the city with enemies and empty its stores.
for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
if (state._isLand(neighbour)) state._spawnUnit(neighbour, 1, state.protoUnits[0]);
@@ -811,7 +867,19 @@ export class ResourceModelTest extends TestCase {
test_an_encircled_unit_cannot_resupply() {
const state = smallState();
- const unit = state.units[0];
+ // A fully inland tile: with every land neighbour held by the enemy there is
+ // no route, land or maritime, back to a friendly city.
+ let spot = null;
+ for (const cell of state.landCells) {
+ if (state.unitAt(cell) || state.cityAt(cell)) continue;
+ const neighbours = state.topology.neighbours(cell.x, cell.y);
+ if (neighbours.every((n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n))) {
+ spot = cell;
+ break;
+ }
+ }
+ this.assertNotNull(spot, "the fixture has an inland tile");
+ const unit = state._spawnUnit(spot, 0, state.protoUnits[0]);
for (const neighbour of state.topology.neighbours(unit.coords.x, unit.coords.y)) {
if (state._isLand(neighbour)) state._spawnUnit(neighbour, 1, state.protoUnits[0]);
}
diff --git a/tests/server_test.js b/tests/server_test.js
index 21e501f..37276ac 100644
--- a/tests/server_test.js
+++ b/tests/server_test.js
@@ -80,7 +80,9 @@ export class ServerStaticTest extends TestCase {
try {
const line = await new Promise((resolve, reject) => {
let out = "";
- const timer = setTimeout(() => reject(new Error("the CLI did not start")), 5000);
+ // The CLI builds the full world (60% land, 18 nations) before it
+ // listens, which is slow when the suite runs one worker per core.
+ const timer = setTimeout(() => reject(new Error("the CLI did not start")), 20000);
child.stdout.on("data", (chunk) => {
out += chunk.toString();
if (out.includes("listening on")) {
diff --git a/tests/siege_test.js b/tests/siege_test.js
index 9dd740f..2657a3a 100644
--- a/tests/siege_test.js
+++ b/tests/siege_test.js
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
-import { smallState, mapLayers, SEED, grantBuilding } from "./framework/helpers.js";
+import { smallState, mapLayers, SEED, grantBuilding, settleInlandCity } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
import { MapView } from "../client/js/map_view.js";
@@ -82,7 +82,11 @@ export class SiegeTest extends TestCase {
test_a_lone_city_can_be_besieged() {
const state = smallState(["france"], SEED, { citiesPerCiv: 1 });
- const city = state.cities[0];
+ // A portless, landlocked city: a coastal one would keep its sea lane open.
+ const city = settleInlandCity(state, 0, (coords) => {
+ const land = state.topology.neighbours(coords.x, coords.y).filter((n) => state._isLand(n));
+ return land.length > 0 && !land.some((n) => state.unitAt(n) || state.cityAt(n));
+ });
this.assertNotNull(city);
surroundTile(state, city.coords, 1);
state._refreshStatuses();
@@ -207,17 +211,20 @@ function surroundTile(state, coords, civ) {
}
function findBesiegableCity(state, civ) {
+ const clear = (coords) => {
+ const land = state.topology.neighbours(coords.x, coords.y).filter((n) => state._isLand(n));
+ return land.length > 0 && !land.some((n) => state.unitAt(n) || state.cityAt(n));
+ };
for (const city of state.cities) {
if (city.civ !== civ) continue;
// A coastal city now starts with a port, which keeps it supplied; besiege
// an inland city instead.
if (state.isCoastalCity(city)) continue;
- const land = state.topology.neighbours(city.coords.x, city.coords.y).filter((n) => state._isLand(n));
- if (land.length === 0) continue;
- if (land.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
- return city;
+ if (clear(city.coords)) return city;
}
- return null;
+ // The strong coastal preference can leave the fixture with no inland city;
+ // settle one on a clear inland tile when it has.
+ return settleInlandCity(state, civ, clear);
}
// A coastal city whose land ring is clear, so a port's sea lane can be tested.
diff --git a/tests/stacking_test.js b/tests/stacking_test.js
index 9d24f3f..68bc426 100644
--- a/tests/stacking_test.js
+++ b/tests/stacking_test.js
@@ -1,5 +1,5 @@
import { TestCase } from "./framework/test_case.js";
-import { smallState } from "./framework/helpers.js";
+import { smallState, stripPorts } from "./framework/helpers.js";
function tileOwnedBy(state, owner) {
for (const [k, tileOwner] of state.territory) {
@@ -124,6 +124,10 @@ export class StackingTest extends TestCase {
export class PillageTest extends TestCase {
test_pillaging_removes_population_and_heals() {
const state = smallState();
+ // A port adds a flat production figure the pillage does not scale, which
+ // would keep the ratio above 0.7; drop the ports so the test isolates the
+ // pillage penalty.
+ stripPorts(state);
const coords = tileOwnedBy(state, 1);
this.assertNotNull(coords, "found an enemy tile");
const k = `${coords.x},${coords.y}`;
diff --git a/tests/testing_mode_test.js b/tests/testing_mode_test.js
index e77715a..3d52a9f 100644
--- a/tests/testing_mode_test.js
+++ b/tests/testing_mode_test.js
@@ -1,5 +1,5 @@
import { TestCase } from "./framework/test_case.js";
-import { smallState } from "./framework/helpers.js";
+import { smallState, settleInlandCity } from "./framework/helpers.js";
import { GameServer } from "../server/game_server.js";
import { isLoopback, isLocalRequest } from "../server/server.js";
@@ -75,7 +75,7 @@ export class TestingModeTest extends TestCase {
test_free_build_still_respects_placement_rules() {
const state = smallState();
- const inland = state.cities.find((c) => c.civ === 0 && !state.isCoastalCity(c));
+ const inland = settleInlandCity(state, 0);
this.assertNotNull(inland, "the fixture has an inland city");
const shipyard = state.protoBuildings.findIndex((b) => b.id === "shipyard");
this.assertFalse(state.requestBuild(inland.id, shipyard, true), "an inland shipyard is refused");
diff --git a/tests/warfare_test.js b/tests/warfare_test.js
index 45eb285..ee4daf6 100644
--- a/tests/warfare_test.js
+++ b/tests/warfare_test.js
@@ -145,7 +145,15 @@ export class TileImprovementTest extends TestCase {
test_a_coastal_cannon_needs_the_sea() {
const state = smallState();
state.budgets.set(0, BIG_BUDGET);
- const inland = ownLand(state);
+ // A coastal preference can put every owned tile on the shore, so reach for
+ // a landlocked tile explicitly.
+ const inland = state.landCells.find((cell) =>
+ state.civAt(cell) === 0 &&
+ !state.unitAt(cell) &&
+ !state.cityAt(cell) &&
+ !state.isCoastalCoords(cell)
+ );
+ this.assertNotNull(inland, "the fixture has an inland tile");
this.assertFalse(
state.requestBuildTileImprovement(0, inland, "coastal_cannon"),
"an inland tile cannot host a coastal cannon"