diff --git a/client/assets/icon_chip_foundry.svg b/client/assets/icon_chip_foundry.svg index 07a17b6..cde2531 100644 --- a/client/assets/icon_chip_foundry.svg +++ b/client/assets/icon_chip_foundry.svg @@ -1,5 +1 @@ - - - dummy icon - chip foundry - + \ No newline at end of file diff --git a/client/assets/icon_coastal_cannon.svg b/client/assets/icon_coastal_cannon.svg index eeeec35..46303ed 100644 --- a/client/assets/icon_coastal_cannon.svg +++ b/client/assets/icon_coastal_cannon.svg @@ -1,5 +1 @@ - - - dummy icon - coastal cannon - + \ No newline at end of file diff --git a/client/assets/icon_radar.svg b/client/assets/icon_radar.svg index 0a3eecc..39d1ab3 100644 --- a/client/assets/icon_radar.svg +++ b/client/assets/icon_radar.svg @@ -1,5 +1 @@ - - - dummy icon - mobile radar - + \ No newline at end of file diff --git a/client/assets/icon_wind_turbines.svg b/client/assets/icon_wind_turbines.svg index d8f84ec..60768df 100644 --- a/client/assets/icon_wind_turbines.svg +++ b/client/assets/icon_wind_turbines.svg @@ -1,5 +1 @@ - - - dummy icon - wind turbines - + \ No newline at end of file diff --git a/client/css/style.css b/client/css/style.css index c0e2bba..9feb445 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -375,6 +375,16 @@ input:focus, select:focus { border-color: var(--accent); } } .resource-icon { vertical-align: middle; } +/* The migration graph, shown in the population map: a shaft and an arrowhead + per city pair, the thickness carrying the people who moved last month. */ +.migration-arrow { pointer-events: none; } +.migration-arrow-head { + position: absolute; + pointer-events: none; + transform-origin: 100% 50%; + clip-path: polygon(0 0, 100% 50%, 0 100%); +} + /* Outlines the selected or inspected tile. The polygon is built in JS from the same warped hexagon as the terrain, so it follows the geography. */ .tile-selection { @@ -787,6 +797,29 @@ input:focus, select:focus { border-color: var(--accent); } border: 1px solid rgba(0, 0, 0, 0.6); flex: none; } +/* The migration key: a small arrow in the same hue as the map's arrows. */ +.map-legend-arrow { + position: relative; + width: 18px; + height: 4px; + border-radius: 2px; + background: #ff3ec8; + flex: none; +} +.map-legend-arrow::after { + content: ""; + position: absolute; + right: -4px; + top: 50%; + transform: translateY(-50%); + border-left: 6px solid #ff3ec8; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; +} +.map-legend-note { + width: 18px; + flex: none; +} /* The inspected tile / city panel is anchored to the bottom right. */ #tile-panel { @@ -1320,6 +1353,25 @@ input:focus, select:focus { border-color: var(--accent); } .central-bank-rate { display: flex; align-items: center; gap: 8px; margin: 8px 0; } .central-bank-rate label { color: var(--muted); } .central-bank-rate input { width: 90px; } +/* The commodity price index chart and its inflation table. */ +.central-bank-prices { margin-top: 6px; } +.line-chart { display: block; width: 100%; height: auto; margin: 6px 0; } +.line-chart-line { vector-effect: non-scaling-stroke; } +.line-chart-baseline { + stroke: rgba(255, 255, 255, 0.25); + stroke-width: 1; + stroke-dasharray: 3 3; +} +.line-chart-label { fill: var(--muted); font-size: 9px; } +.inflation-table { margin-top: 4px; } +.inflation-swatch { + display: inline-block; + width: 10px; + height: 3px; + margin-right: 6px; + border-radius: 2px; + vertical-align: middle; +} .tax-table .tax-input { width: 52px; } .tax-table .tax-apply { padding: 2px 8px; } .subhead { margin-top: 10px; color: var(--muted); font-weight: 600; } @@ -1351,6 +1403,9 @@ input:focus, select:focus { border-color: var(--accent); } .diplomacy-table td.status { white-space: nowrap; } .diplomacy-table td.status .aero { margin-left: 8px; padding: 4px 10px; } +/* A foreign figure's equivalent in our money, kept quiet beside it. */ +.conversion { color: var(--muted); font-size: 0.85em; } + /* Budget table: income green, every cost red, alternating rows and the net total pinned to the last row. */ .budget-table { width: 100%; border-collapse: collapse; } diff --git a/client/index.html b/client/index.html index a2eb631..d6ba568 100644 --- a/client/index.html +++ b/client/index.html @@ -137,7 +137,7 @@ diff --git a/client/js/currency.js b/client/js/currency.js index 625f688..2554cfc 100644 --- a/client/js/currency.js +++ b/client/js/currency.js @@ -2,12 +2,57 @@ // every money string reads in the right unit. The symbol is set from the // snapshot; until then a neutral mark stands in. +import { groupDigits } from "../../shared/text_format.js"; + let symbol = "¤"; +// Every nation's currency and the instant rate from ours, indexed by civ, so a +// foreign figure can be shown in its own money with our equivalent beside it. +let currencies = []; +let rates = []; +let viewer = -1; export function setCurrency(currency) { if (currency && currency.symbol) symbol = currency.symbol; } +// The per-nation currency list and the snapshot's instant exchange rates, so +// any figure can be re-quoted in the nation it belongs to. +export function setCurrencyBook(list, exchangeRates, viewerCiv) { + currencies = Array.isArray(list) ? list : []; + rates = Array.isArray(exchangeRates) ? exchangeRates : []; + viewer = Number.isInteger(viewerCiv) ? viewerCiv : -1; +} + export function currencySymbol() { return symbol; } + +export function viewerCurrencyCiv() { + return viewer; +} + +export function currencyFor(civ) { + return currencies[civ] || null; +} + +// How many units of `civ`'s currency one unit of the viewer's buys, at the +// snapshot's instant rate. One for our own currency, or when it is unknown. +export function rateToNational(civ) { + if (civ === viewer) return 1; + const entry = rates[civ]; + if (entry && Number.isFinite(entry.rate)) return entry.rate; + const mine = currencies[viewer]; + const theirs = currencies[civ]; + if (mine && theirs && theirs.value > 0) return (mine.value || 1) / theirs.value; + return 1; +} + +// A money amount belonging to `civ` as plain text: their money first, then ours +// in parentheses when they differ. `format` renders a bare number, so callers +// keep their own rounding and compacting. +export function moneyText(value, civ, format = (v) => groupDigits(Math.round(v))) { + const ours = `${currencySymbol()}${format(value)}`; + const info = currencyFor(civ); + if (!info || civ === viewer) return ours; + return `${info.symbol}${format(value * rateToNational(civ))} (${ours})`; +} diff --git a/client/js/game_screen.js b/client/js/game_screen.js index b52cd18..60e80a6 100644 --- a/client/js/game_screen.js +++ b/client/js/game_screen.js @@ -17,7 +17,7 @@ import { panelMethods } from "./game_screen/panels.js"; import { CompletionWatcher } from "./game_screen/completions.js"; import { BombardmentSfx, AirStrikeSfx, zoomVolume } from "./game_screen/bombing.js"; import { audio } from "./audio.js"; -import { setCurrency } from "./currency.js"; +import { setCurrency, setCurrencyBook } from "./currency.js"; export class GameScreen { constructor(network, config) { @@ -165,6 +165,11 @@ export class GameScreen { this.snapshot = state; this.localCiv = state.viewer; setCurrency(state.viewerStats && state.viewerStats.currency); + setCurrencyBook( + state.viewerStats && state.viewerStats.currencies, + state.viewerStats && state.viewerStats.exchangeRates, + state.viewer + ); this.testing = !!(state.testing || (this.config && this.config.testing)); this.civilisations = state.civs.map( (c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" } @@ -240,6 +245,7 @@ export class GameScreen { // The delivery graph is delta-dropped while unchanged, so only replace the // view's copy when the snapshot actually carried one. if (state.resourceGraph) this.map.setResourceGraph(state.resourceGraph); + if (state.migrationGraph) this.map.setMigrationGraph(state.migrationGraph); // Refresh an active economic map mode against the new figures. this._refreshEconomicMode(); this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id)); diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js index 9b2128b..a39a100 100644 --- a/client/js/game_screen/panels.js +++ b/client/js/game_screen/panels.js @@ -39,7 +39,7 @@ import { ethnicityChart } from "../ui/ethnicity.js"; import { isEconomicMode } from "../map_view/economic.js"; import { isWaterTile } from "../map_view/textures.js"; import { audio } from "../audio.js"; -import { currencySymbol } from "../currency.js"; +import { currencySymbol, currencyFor, rateToNational, moneyText } from "../currency.js"; export const panelMethods = { _bindHud() { @@ -923,7 +923,8 @@ export const panelMethods = { $("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`); this._renderTilePeople(k); this._renderTileProduction( - this._tileResourceProduction(coords, owner, city, tile) + this._tileResourceProduction(coords, owner, city, tile), + owner ); this._renderRegionGdp(owner >= 0 ? regionCity : null); const transport = this.map.railways.has(k) @@ -1539,7 +1540,7 @@ export const panelMethods = { // The tile panel's headline: what the tile actually produces, one row per // resource (nothing is shown for a resource the tile does not make), with the // modifiers that shape it in a hover tooltip. - _renderTileProduction(production) { + _renderTileProduction(production, owner = -1) { const $container = $("#tile-production").empty(); const rows = []; for (const resource of RESOURCES) { @@ -1552,7 +1553,7 @@ export const panelMethods = { notes.push(`${factor.label}: ×${factor.multiplier.toFixed(2)}`); } else if (factor.amount) { const sign = factor.amount < 0 ? "−" : "+"; - notes.push(`${factor.label}: ${sign}${currencySymbol()}${groupDigits(Math.round(Math.abs(factor.amount)))}`); + notes.push(`${factor.label}: ${sign}${moneyText(Math.abs(factor.amount), owner)}`); } } if (resource.id === "food" && production.foodMultiplier !== 1) { @@ -1589,11 +1590,25 @@ export const panelMethods = { const stats = this._cityStats(regionCity.id); const population = stats.population || 0; const perCapita = population > 0 ? (stats.gdp || 0) / population : 0; - $container.append( - $("
").text( - `Region GDP/capita: ${currencySymbol()}${groupDigits(Math.round(perCapita))}` - ) - ); + const $head = $("
").text("Region GDP/capita: "); + const info = regionCity.civ === this.localCiv ? null : currencyFor(regionCity.civ); + if (info) { + $head.append( + document.createTextNode( + `${info.symbol}${groupDigits(Math.round(perCapita * rateToNational(regionCity.civ)))}` + ) + ); + $head.append( + $("").text( + ` (${currencySymbol()}${groupDigits(Math.round(perCapita))})` + ) + ); + } else { + $head.append( + document.createTextNode(`${currencySymbol()}${groupDigits(Math.round(perCapita))}`) + ); + } + $container.append($head); }, // The tile/city panel and the unit panel are independent docks, so either @@ -1815,6 +1830,10 @@ export const panelMethods = { takeTax: stats.taxTake || { sales: 0, export: 0, import: 0 }, // The world commodity price move of the last day, for the central bank. commodityInflation: stats.commodityInflation || 0, + // The monthly world-price series and each commodity's index and + // monthly/yearly inflation, for the central bank's chart. + resourcePriceHistory: (this.snapshot && this.snapshot.resourcePriceHistory) || [], + resourceInflation: (this.snapshot && this.snapshot.resourceInflation) || [], cities: (this.snapshot && this.snapshot.cityStats) || [], }; }, diff --git a/client/js/map_view.js b/client/js/map_view.js index 914b72c..c0e874c 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -58,6 +58,9 @@ export class MapView { else this.$world.append(this.$resources); } this.resourceGraph = { nodes: [], links: [] }; + // City-to-city migration over the last month, drawn instead of the delivery + // graph while the population map is active. + this.migrationGraph = { links: [] }; this.$entities = layers.entities; // City names live on their own layer above the entities so they are never // hidden behind a unit or city marker. @@ -148,6 +151,7 @@ export class MapView { // The colour scale shown while an economic mode is active. this.$legend = $("#map-legend"); this.$legendGraph = $("#map-legend-graph"); + this.$legendGraphTitle = $("#map-legend-graph-title"); this.$legendResources = $("#map-legend-resources"); this._labelSvg = null; this._labelEntries = []; diff --git a/client/js/map_view/modes.js b/client/js/map_view/modes.js index 95036bc..50493c3 100644 --- a/client/js/map_view/modes.js +++ b/client/js/map_view/modes.js @@ -9,11 +9,18 @@ import { POLITICAL_ZOOM } from "./constants.js"; import { ECONOMIC_GRADIENT, ECONOMIC_MODES, economicColour, isEconomicMode } from "./economic.js"; import { NEUTRAL_LAND, POLITICAL_WATER } from "./political.js"; import { isWaterTile } from "./textures.js"; -import { makeSegment } from "./utils.js"; +import { makeSegment, makeArrow } from "./utils.js"; +import { compact } from "../../../shared/text_format.js"; import { RESOURCES } from "../../../shared/data/resources.js"; export const MAP_MODES = ["terrain", "political", "gdp", "population"]; +// The migration arrows: one hue so they stay legible over the population +// gradient, with the month's flow mapped between these thicknesses. +const MIGRATION_COLOUR = "#ff3ec8"; +const MIGRATION_ARROW_MIN = 1.5; +const MIGRATION_ARROW_MAX = 12; + export const modeMethods = { // Selects a mode and repaints. Terrain mode keeps the far-out auto-switch, so // "political" forces the political map at any zoom while "terrain" only shows @@ -47,6 +54,19 @@ export const modeMethods = { return `${this.economicKind}:${this.economicValues.size}:${Math.round(sum)}`; }, + // -------------------------------------------------- migration graph ----- + + // Replaces the migration graph shipped in the snapshot. It is only drawn in + // the population mode, which is an economic mode, so a political or terrain + // map never reveals it. + setMigrationGraph(graph) { + if (!graph) return; + this.migrationGraph = { + links: Array.isArray(graph.links) ? graph.links : [], + }; + this._renderResourceGraph(); + }, + // ------------------------------------------------- delivery graph ------ // Replaces the delivery graph shipped in the snapshot. It is only drawn in @@ -60,16 +80,26 @@ export const modeMethods = { this._renderResourceGraph(); }, - // Draws one line per delivery leg and a dot on every storage node. The layer - // shares the world's coordinate system, so pan and zoom move it for free. + // Draws the economic overlay's graph. The population map carries the + // migration arrows; every other economic mode carries the delivery graph of + // storage nodes and delivery legs. The layer shares the world's coordinate + // system, so pan and zoom move it for free. _renderResourceGraph() { if (!this.$resources || !this.$resources.length) return; - const graph = this.resourceGraph || { nodes: [], links: [] }; + const migration = this.mapMode === "population"; + const graph = migration ? this.migrationGraph : this.resourceGraph; if (!this.economic || graph.links.length === 0) { if (this.$resources[0].firstChild) this.$resources.empty(); - this._renderTradeLegend(); + this._renderGraphLegend(); return; } + if (migration) this._renderMigrationArrows(graph.links); + else this._renderDeliveryLines(graph); + this._renderGraphLegend(); + }, + + // One line per delivery leg and a dot on every storage node. + _renderDeliveryLines(graph) { const colours = Object.fromEntries(RESOURCES.map((r) => [r.id, r.colour])); const fragment = document.createDocumentFragment(); for (const link of graph.links) { @@ -87,22 +117,58 @@ export const modeMethods = { fragment.appendChild(dot); } this.$resources.empty()[0].appendChild(fragment); - this._renderTradeLegend(); }, - // The key for the trade graph: one swatch per commodity whose deliveries are - // on screen, plus the storage-node marker. Shown only with the graph. - _renderTradeLegend() { + // One arrow per city pair, its thickness scaled to the people who moved over + // the month so the volume reads at a glance. + _renderMigrationArrows(links) { + let max = 0; + for (const link of links) if (link[4] > max) max = link[4]; + const fragment = document.createDocumentFragment(); + for (const link of links) { + const [fromX, fromY, toX, toY, people] = link; + const a = mapToLocal(fromX, fromY); + const b = mapToLocal(toX, toY); + const width = MIGRATION_ARROW_MIN + + (MIGRATION_ARROW_MAX - MIGRATION_ARROW_MIN) * (max > 0 ? people / max : 0); + fragment.appendChild(makeArrow(a, b, MIGRATION_COLOUR, width)); + } + this.$resources.empty()[0].appendChild(fragment); + }, + + // The key for the economic overlay's graph. The population mode keys the + // migration arrows; every other mode keys the commodities and storage nodes. + // Shown only with a graph. + _renderGraphLegend() { const $el = this.$legendGraph; if (!$el || !$el.length) return; - const graph = this.resourceGraph || { nodes: [], links: [] }; + const migration = this.mapMode === "population"; + const graph = migration ? this.migrationGraph : this.resourceGraph; if (!this.economic || graph.links.length === 0) { $el.addClass("hidden"); return; } $el.removeClass("hidden"); - const present = new Set(graph.links.map((link) => link[4])); + if (this.$legendGraphTitle && this.$legendGraphTitle.length) { + this.$legendGraphTitle.text(migration ? "Migration (last month)" : "Trade routes"); + } const $list = this.$legendResources.empty(); + if (migration) { + let max = 0; + for (const link of graph.links) if (link[4] > max) max = link[4]; + $list.append( + $("
") + .append($("")) + .append($("").text("People between cities")) + ); + $list.append( + $("
") + .append($("")) + .append($("").text(`Busiest route: ${compact(max)}`)) + ); + return; + } + const present = new Set(graph.links.map((link) => link[4])); for (const resource of RESOURCES) { if (!present.has(resource.id)) continue; $list.append( diff --git a/client/js/map_view/utils.js b/client/js/map_view/utils.js index 55664c7..fd9c068 100644 --- a/client/js/map_view/utils.js +++ b/client/js/map_view/utils.js @@ -116,6 +116,36 @@ export function makeSegment(a, b, colour, width, offset = 0) { return div; } +// A shaft plus a triangular head, from `a` to `b`, with the tip landing on +// `b`. `width` sets the shaft's thickness; the head grows with it so a thick +// arrow still reads as an arrow. +export function makeArrow(a, b, colour, width) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy); + const angle = Math.atan2(dy, dx); + const head = Math.max(8, width * 2.4); + const group = document.createElement("div"); + group.className = "migration-arrow"; + if (length > head) { + const shaftEnd = { + x: b.x - (dx / length) * head, + y: b.y - (dy / length) * head, + }; + group.appendChild(makeSegment(a, shaftEnd, colour, width)); + } + const tip = document.createElement("div"); + tip.className = "migration-arrow-head"; + tip.style.left = `${b.x - head}px`; + tip.style.top = `${b.y - head / 2}px`; + tip.style.width = `${head}px`; + tip.style.height = `${head}px`; + tip.style.background = colour; + tip.style.transform = `rotate(${angle}rad)`; + group.appendChild(tip); + return group; +} + export function parseColour(value) { if (value.charAt(0) === "#") { const hex = value.slice(1); diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 9850fc5..37239c2 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -20,9 +20,11 @@ import { renderMonthlyBudget } from "./budget.js"; import { effectsText, money, quotedMoney } from "./format.js"; import { bindTabs } from "./tabs.js"; import { ethnicityChart } from "../ui/ethnicity.js"; +import { lineChart } from "../ui/line_chart.js"; import { trendArrow } from "../ui/trend.js"; -import { currencySymbol } from "../currency.js"; +import { currencySymbol, currencyFor, rateToNational } from "../currency.js"; import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js"; +import { MONTH_NAMES } from "../../../shared/game_clock.js"; export class NationModal { constructor() { @@ -655,18 +657,24 @@ export class NationModal { } // The central bank: its interest rate, which compounds into every region's - // private cash, and the foreign reserves it has accumulated. + // private cash, the world commodity price index and its monthly and yearly + // inflation, and the foreign reserves it has accumulated. _renderCentralBank(money) { if (!this.$economyCentralBank || !this.$economyCentralBank.length) return; const bank = money.centralBank || { rate: 0, reserves: [] }; - // The consumption-weighted move in world commodity prices over the last day. - const inflation = (money && money.commodityInflation) || 0; - const inflationText = `${inflation >= 0 ? "+" : "−"}${(Math.abs(inflation) * 100).toFixed(2)}%/day`; + const entries = money.resourceInflation || []; + const average = (field) => entries.length + ? entries.reduce((sum, entry) => sum + (entry[field] || 0), 0) / entries.length + : 0; + const percent = (value) => + `${value >= 0 ? "+" : "−"}${(Math.abs(value) * 100).toFixed(2)}%`; + const monthly = average("monthly"); + const yearly = average("yearly"); if (this.$economySub && this.$economySub.length) { this.$economySub.text( `${(money.currency && money.currency.name) || "Central bank"} · ` + `Interest rate ${(bank.rate * 100).toFixed(2)}%/year · ` + - `Commodity inflation ${inflationText}` + `Commodity inflation ${percent(monthly)}/month, ${percent(yearly)}/year` ); } if (!this._centralBankBuilt) { @@ -676,9 +684,9 @@ export class NationModal { const $input = $(""); const $apply = $("").text("Set"); const apply = () => { - const percent = Number($input.val()); - if (!Number.isFinite(percent) || percent < 0) return; - this.onSetInterestRate(percent / 100); + const percentValue = Number($input.val()); + if (!Number.isFinite(percentValue) || percentValue < 0) return; + this.onSetInterestRate(percentValue / 100); }; $apply.on("click", apply); $input.on("keydown", (event) => { @@ -694,6 +702,7 @@ export class NationModal { ) ); this.$economyCentralBank.append($("
")); + this.$economyCentralBank.append($("
")); this.$economyCentralBank.append($("
")); const $reserves = $("
"); this.$economyCentralBank.append($reserves); @@ -704,9 +713,10 @@ export class NationModal { $input.val((bank.rate * 100).toFixed(2)); } this.$economyCentralBank.find(".central-bank-inflation").text( - `Commodity inflation: ${inflationText} — the price of the goods the world ` + - "consumes, weighted by how much of each it uses." + `Commodity inflation: ${percent(monthly)} over the month and ${percent(yearly)} ` + + "over the year — the average move in the world's commodity prices." ); + this._renderCommodityChart(money); this.$economyCentralBank.find(".central-bank-loans").text( `Outstanding loans: ${currencySymbol()}${groupDigits(Math.round(bank.loans || 0))}` ); @@ -734,6 +744,71 @@ export class NationModal { $reserves.append($table); } + // The commodity price index chart and the per-commodity inflation table. Each + // series is a commodity's world price rebased to 100 in January 2000, with the + // live point appended so the current month reads to today. + _renderCommodityChart(money) { + const $container = this.$economyCentralBank.find(".central-bank-prices"); + if (!$container.length) return; + const history = money.resourcePriceHistory || []; + const inflation = new Map((money.resourceInflation || []).map((entry) => [entry.id, entry])); + const series = []; + const rows = []; + for (const [id, prices] of history) { + const resource = resourceById(id); + if (!resource || !prices || prices.length === 0) continue; + const base = prices[0] > 0 ? prices[0] : 1; + const values = prices.map((price) => (price / base) * 100); + const info = inflation.get(id) || { index: 100, monthly: 0, yearly: 0 }; + if (Math.abs(info.index - values[values.length - 1]) > 1e-6) values.push(info.index); + series.push({ label: resource.name, colour: resource.colour, values }); + rows.push({ resource, info }); + } + $container.empty(); + $container.append($("
").text("Commodity prices")); + $container.append( + $("
").text( + "Each commodity's world price as an index, 100 in January 2000." + ) + ); + if (series.length === 0) return; + const chart = lineChart(series, { + xLabel: (i) => `${MONTH_NAMES[i % 12].slice(0, 3)} ${2000 + Math.floor(i / 12)}`, + }); + $container.append(chart); + const percent = (value) => + `${value >= 0 ? "+" : "−"}${(Math.abs(value) * 100).toFixed(2)}%`; + const $table = $("
"); + $table.append( + $("").append( + $("") + .append($("").text("Commodity")) + .append($("").text("Index")) + .append($("").text("Monthly")) + .append($("").text("Yearly")) + ) + ); + const $body = $(""); + for (const row of rows) { + $body.append( + $("") + .append( + $("") + .append( + $("") + .css("background", row.resource.colour) + ) + .append(document.createTextNode(row.resource.name)) + ) + .append($("").text(row.info.index.toFixed(1))) + .append($("").text(percent(row.info.monthly))) + .append($("").text(percent(row.info.yearly))) + ); + } + $table.append($body); + $container.append($table); + } + // The exchange rate between the viewer's currency and every other. _renderExchangeRates(money) { if (!this.$economyRates || !this.$economyRates.length) return; @@ -1266,8 +1341,8 @@ export class NationModal { const $tr = $(""); if (row.self) $tr.addClass("local-nation"); $tr.append($("").text(row.name)); - $tr.append($("").text(`${currencySymbol()}${compact(row.gdp)}`)); - $tr.append($("").text(`${currencySymbol()}${compact(row.gdpPerCapita)}`)); + $tr.append(this._moneyCell(row.gdp, row.index, row.self)); + $tr.append(this._moneyCell(row.gdpPerCapita, row.index, row.self)); $tr.append($("").text(compact(row.population))); $tr.append($("").text(`${Math.round(row.approval * 100)}%`)); const $status = $(""); @@ -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"