Reworked the world around a fixed sea share and settled coasts
World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land. Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built. Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
@@ -1,5 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">chip foundry</text>
|
||||
</svg>
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M228.844 32.22v114.218h17.687V32.218h-17.686zm-108.25.624c-15.507 0-28.094 12.586-28.094 28.093S105.087 89 120.594 89c12.655 0 23.34-8.372 26.844-19.875h44.937v77.313h17.688v-95H147.03c-3.888-10.837-14.262-18.593-26.436-18.593zm193.25 0c-15.507 0-28.063 12.586-28.063 28.093 0 12.124 7.677 22.45 18.44 26.376v59.124h17.655V87.844c11.596-3.452 20.063-14.193 20.063-26.906 0-15.508-12.587-28.094-28.094-28.094zM266.124 92.5v53.938h17.657V92.5h-17.655zm188.532 4.03c-15.507 0-28.094 12.588-28.094 28.095 0 13.083 8.948 24.074 21.063 27.188v27.468h-92.938v17.657h110.624v-46.342c10.223-4.192 17.407-14.233 17.407-25.97 0-15.507-12.557-28.094-28.064-28.094zM30.187 123.657v17.688H96.75v55.594h62.814V179.28h-45.126v-55.624h-84.25zm147.032 40.47v159.718h159.81v-159.72H177.22zm17.56 15.655h17.657v78.595l32.407 32.406h75.28v17.658H237.5l-2.594-2.594-10.75-10.75c-1.033 7.385-7.36 13.062-15.03 13.062-8.392 0-15.19-6.796-15.19-15.187 0-7.682 5.696-13.98 13.095-15l-9.655-9.658-2.594-2.593V179.78zm54.94.157h17.686v55.313h52.53l.002 17.688H249.72v-73zM53.124 217.375v89.969c-11.49 3.512-19.844 14.198-19.844 26.844 0 15.505 12.557 28.093 28.064 28.093s28.093-12.587 28.093-28.092c0-12.195-7.79-22.564-18.656-26.438v-72.72h88.782v-17.655H53.124zm301.563 0v17.656h53.968v-17.655h-53.97zm99.968 21.97c-10.898 0-20.342 6.21-25 15.28h-74.97l.002 17.688H427c2.325 13.168 13.824 23.187 27.656 23.187 15.507 0 28.063-12.588 28.063-28.094 0-15.507-12.557-28.062-28.064-28.062zm-349.062 15.28v17.688h53.97v-17.688h-53.97zm17.156 36.47v84.217c-11.498 3.513-19.875 14.2-19.875 26.844 0 15.506 12.587 28.094 28.094 28.094 15.506 0 28.06-12.588 28.06-28.094 0-12.194-7.766-22.564-18.624-26.437v-66.94h19.156v-17.686h-36.81zm231.938 0v17.686h45.156v95.283c-11.323 3.624-19.53 14.26-19.53 26.78-.002 15.506 12.585 28.063 28.092 28.063 15.507 0 28.063-12.557 28.063-28.062 0-12.32-7.935-22.778-18.97-26.563V291.095h-62.814zM192.375 341.53v54.033h17.688V341.53h-17.688zm36.47 0v86.564c-11.013 3.794-18.94 14.233-18.94 26.53 0 15.506 12.588 28.095 28.095 28.095s28.063-12.59 28.063-28.095c0-12.53-8.203-23.14-19.532-26.75V341.53h-17.686zm37.28 0v54.033h17.688l-.032-54.032h-17.655zm38.094 0v140.064h17.655V341.53H304.22z" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 2.4 KiB |
@@ -1,5 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">coastal cannon</text>
|
||||
</svg>
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M406.4 67.25c-2.1 0-4 .8-5.7 1.9-4.3 2.9-7.6 8.4-.8 18.6l53.4 79.85c6.8 10.2 13.2 9.3 17.5 6.4 4.4-2.9 7.7-8.4.9-18.6l-53.5-79.85c-4.2-6.4-8.3-8.4-11.8-8.3zM392 108.4l-141.2 88.5c4.6 12.4 12.1 26.2 21.1 38.8l1.8 2.4a24 24 0 0 1 3.6-.3 24 24 0 0 1 22.2 15h21.6l109.2-87.2zm-156.8 98.3-99.1 62.2c4.1 17.3 11.5 33.6 21.7 47.9h54.5v-64h42.8a24 24 0 0 1 3-5.4c-.3-.4-.6-.9-.9-1.3-9-12.6-16.7-26.1-22-39.4zm-4.9 64.1v64h-64v64h-64v46h209.1c-6.9-8.5-11.1-19.3-11.1-31 0-23.9 17.3-43.9 40-48.2v-94.8zm-110.2 8.1-34.2 21.5c-25.6 18.3-12.3 58.4 11.54 80.4h50.86v-46.6c-12.9-16.3-22.6-35.1-28.2-55.3zm309.2 39.9c-17.2 0-31 13.8-31 31 0 5.6 1.4 10.8 4 15.3 10.7 1 20.4 5.6 28 12.5 7-6.4 16-10.8 25.9-12.3 2.6-4.5 4.1-9.8 4.1-15.5 0-17.2-13.8-31-31-31zM66.66 370.9c-3.61 4-8.24 7.8-13.57 11-11.26 6.8-25.19 11.1-35.41 11.4l.58 18c14.31-.5 30.29-5.6 44.18-14 5.38-3.3 10.5-7.1 14.96-11.5-4-4.6-7.61-9.6-10.74-14.9zm282.64 11.9c-17.2 0-31 13.8-31 31s13.8 31 31 31c3.2 0 6.2-.5 9-1.3-6.2-8.3-10-18.6-10-29.7 0-11.1 3.8-21.4 10-29.7-2.8-.8-5.8-1.3-9-1.3zm48 0c-17.2 0-31 13.8-31 31s13.8 31 31 31 31-13.8 31-31-13.8-31-31-31zm66 0c-8.7 0-16.5 3.5-22.1 9.2 3.2 6.6 5.1 14 5.1 21.8 0 7.8-1.9 15.2-5.1 21.8 5.6 5.7 13.4 9.2 22.1 9.2 17.2 0 31-13.8 31-31s-13.8-31-31-31z" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">mobile radar</text>
|
||||
</svg>
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M252.78 20.875c-1.302.012-2.6.03-3.905.063-37.928.974-76.148 11.153-111.28 31.437C25.164 117.285-13.41 261.322 51.5 373.75s208.946 151.036 321.375 86.125c77.7-44.86 120.1-127.513 117.47-211.406-3.563 65.847-35.898 128.573-91 169.374a217.993 217.993 0 0 1-35.814 25.844c-103.68 59.86-235.983 24.4-295.842-79.282-59.86-103.68-24.43-235.984 79.25-295.844 35.64-20.576 74.67-29.88 112.968-29.03 63.304 1.4 124.623 30.57 165.438 82.53l-32.594 23.032c-33.27-42.835-84.01-66.6-136.063-67-.96-.008-1.91-.012-2.875 0-.964.01-1.943.038-2.906.062-28.006.717-56.222 8.215-82.156 23.188-82.99 47.914-111.508 154.322-63.594 237.312 47.914 82.99 154.32 111.51 237.313 63.594 51.37-29.66 81.862-81.724 86.28-136.78-12.53 45.37-42.32 86.745-85.438 114.186-.02.013-.043.018-.062.03l-.344.22a158.059 158.059 0 0 1-9.78 6.156c-74.245 42.865-168.918 17.494-211.782-56.75-42.864-74.243-17.493-168.917 56.75-211.78 23.2-13.396 48.39-20.122 73.375-20.782 47.953-1.266 95.138 19.858 125.968 59.156l-39.844 28.156c-20.232-24.32-50.055-37.79-80.594-38.03-1.17-.01-2.33 0-3.5.03-17.035.432-34.176 4.995-49.938 14.094-50.435 29.12-67.806 93.877-38.687 144.313 29.12 50.434 93.908 67.806 144.344 38.686 21.245-12.267 36.623-30.85 45.124-52.03-18.815 21.064-44.364 36.888-73.938 44.155-.04.013-.084.02-.125.033-37.507 10.787-78.796-4.816-99.217-40.188-24.07-41.688-9.845-94.712 31.843-118.78 13.028-7.523 27.143-11.314 41.156-11.69 25.66-.685 50.898 10.098 68.188 30.25l-41 28.97c-5.497-4.796-12.664-7.72-20.53-7.72-17.277 0-31.283 14.007-31.283 31.282 0 17.276 14.004 31.282 31.282 31.282 17.277 0 31.28-14.007 31.28-31.283 0-1.187-.06-2.347-.188-3.5l120.094-57.312 4.03-1.75-.06-.156 62.25-29.72 9.25-4.438-5.282-8.812-19.97-33.375-5.155-8.625-8.25 5.813-8.095 5.718c-45.9-58.864-116.14-91.053-187.844-90.405z" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 2.0 KiB |
@@ -1,5 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height:512px;width:512px">
|
||||
<rect x="8" y="8" width="496" height="496" rx="24" fill="#2b3550" stroke="#ffffff" stroke-width="12"/>
|
||||
<text x="256" y="266" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">dummy icon</text>
|
||||
<text x="256" y="316" fill="#ffffff" font-family="sans-serif" font-size="40" font-weight="700" text-anchor="middle">wind turbines</text>
|
||||
</svg>
|
||||
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M272 16c-32 48-38.97 115.99-38 176 .118 7.3 2.486 17.54 5.086 26.69A40.6 40.6 0 0 1 256 215c3.04 0 6 .346 8.857.982L272 16zm-16 217c-12.81 0-23 10.19-23 23s10.19 23 23 23 23-10.19 23-23-10.19-23-23-23zm40.8 27.033c-.897 9.054-4.776 17.268-10.632 23.643L455.812 389.87c-25.57-51.714-80.964-91.744-133.42-120.91-6.366-3.54-16.386-6.605-25.593-8.927zm-79.896 8.274L40.124 362.12c57.57 3.714 119.937-24.243 171.423-55.09 6.274-3.758 13.95-10.936 20.58-17.77-7.1-5.122-12.522-12.447-15.223-20.953zm55.86 25.07A40.563 40.563 0 0 1 256 297a40.592 40.592 0 0 1-16.764-3.62L232 496h48l-7.236-202.623z" fill="#ffffff" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>
|
||||
|
Before Width: | Height: | Size: 483 B After Width: | Height: | Size: 851 B |
@@ -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; }
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
<span id="map-legend-max"></span>
|
||||
</div>
|
||||
<div id="map-legend-graph" class="map-legend-graph hidden">
|
||||
<div class="map-legend-title">Trade routes</div>
|
||||
<div id="map-legend-graph-title" class="map-legend-title">Trade routes</div>
|
||||
<div id="map-legend-resources" class="map-legend-resources"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
$("<div class='tile-gdp-head'></div>").text(
|
||||
`Region GDP/capita: ${currencySymbol()}${groupDigits(Math.round(perCapita))}`
|
||||
)
|
||||
);
|
||||
const $head = $("<div class='tile-gdp-head'></div>").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(
|
||||
$("<span class='conversion'></span>").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) || [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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(
|
||||
$("<div class='map-legend-resource'></div>")
|
||||
.append($("<span class='map-legend-arrow'></span>"))
|
||||
.append($("<span></span>").text("People between cities"))
|
||||
);
|
||||
$list.append(
|
||||
$("<div class='map-legend-resource'></div>")
|
||||
.append($("<span class='map-legend-note'></span>"))
|
||||
.append($("<span></span>").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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = $("<input id='cb-rate' type='number' min='0' max='50' step='0.25'>");
|
||||
const $apply = $("<button type='button' class='aero cb-apply'></button>").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($("<div class='central-bank-inflation'></div>"));
|
||||
this.$economyCentralBank.append($("<div class='central-bank-prices'></div>"));
|
||||
this.$economyCentralBank.append($("<div class='central-bank-loans'></div>"));
|
||||
const $reserves = $("<div class='central-bank-reserves'></div>");
|
||||
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($("<div class='subhead'></div>").text("Commodity prices"));
|
||||
$container.append(
|
||||
$("<div class='desc'></div>").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 class='resources-table economy-table inflation-table'></table>");
|
||||
$table.append(
|
||||
$("<thead></thead>").append(
|
||||
$("<tr></tr>")
|
||||
.append($("<th></th>").text("Commodity"))
|
||||
.append($("<th class='numeric'></th>").text("Index"))
|
||||
.append($("<th class='numeric'></th>").text("Monthly"))
|
||||
.append($("<th class='numeric'></th>").text("Yearly"))
|
||||
)
|
||||
);
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const row of rows) {
|
||||
$body.append(
|
||||
$("<tr></tr>")
|
||||
.append(
|
||||
$("<td></td>")
|
||||
.append(
|
||||
$("<span class='inflation-swatch'></span>")
|
||||
.css("background", row.resource.colour)
|
||||
)
|
||||
.append(document.createTextNode(row.resource.name))
|
||||
)
|
||||
.append($("<td class='numeric'></td>").text(row.info.index.toFixed(1)))
|
||||
.append($("<td class='numeric'></td>").text(percent(row.info.monthly)))
|
||||
.append($("<td class='numeric'></td>").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 = $("<tr></tr>");
|
||||
if (row.self) $tr.addClass("local-nation");
|
||||
$tr.append($("<td class='name'></td>").text(row.name));
|
||||
$tr.append($("<td class='numeric'></td>").text(`${currencySymbol()}${compact(row.gdp)}`));
|
||||
$tr.append($("<td class='numeric'></td>").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($("<td class='numeric'></td>").text(compact(row.population)));
|
||||
$tr.append($("<td class='numeric'></td>").text(`${Math.round(row.approval * 100)}%`));
|
||||
const $status = $("<td class='status'></td>");
|
||||
@@ -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 = $("<td class='numeric'></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(
|
||||
$("<span class='conversion'></span>").text(` (${currencySymbol()}${compact(value)})`)
|
||||
);
|
||||
return $td;
|
||||
}
|
||||
|
||||
// Clicking a header sorts by it, reversing the order when it is already the
|
||||
// active column.
|
||||
_sortDiplomacyBy(key) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -148,6 +148,7 @@ export const worldMethods = {
|
||||
this.cities.push(city);
|
||||
this._cityById.set(this._nextCityId, city);
|
||||
this._nextCityId += 1;
|
||||
return city;
|
||||
},
|
||||
|
||||
_rebuildCityIndex() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||