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.
385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
|
import { MapView } from "../client/js/map_view.js";
|
|
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
|
|
import {
|
|
ECONOMIC_GRADIENT,
|
|
ECONOMIC_MODES,
|
|
economicColour,
|
|
economicFraction,
|
|
} from "../client/js/map_view/economic.js";
|
|
import { smallState, mapLayers } from "./framework/helpers.js";
|
|
|
|
function cssRgb(hex) {
|
|
const value = hex.replace("#", "");
|
|
const r = parseInt(value.slice(0, 2), 16);
|
|
const g = parseInt(value.slice(2, 4), 16);
|
|
const b = parseInt(value.slice(4, 6), 16);
|
|
return `rgb(${r}, ${g}, ${b})`;
|
|
}
|
|
|
|
function luminance(hex) {
|
|
const value = hex.replace("#", "");
|
|
const r = parseInt(value.slice(0, 2), 16);
|
|
const g = parseInt(value.slice(2, 4), 16);
|
|
const b = parseInt(value.slice(4, 6), 16);
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
}
|
|
|
|
export class MapModesTest extends TestCase {
|
|
async test_economic_fraction_clamps_and_centres_a_flat_range() {
|
|
this.assertApprox(economicFraction(0, 0, 10), 0, 1e-9, "the minimum is zero");
|
|
this.assertApprox(economicFraction(10, 0, 10), 1, 1e-9, "the maximum is one");
|
|
this.assertApprox(economicFraction(5, 0, 10), 0.5, 1e-9, "the middle is halfway");
|
|
this.assertApprox(economicFraction(-4, 0, 10), 0, 1e-9, "below the range clamps");
|
|
this.assertApprox(economicFraction(40, 0, 10), 1, 1e-9, "above the range clamps");
|
|
this.assertApprox(economicFraction(7, 7, 7), 0.5, 1e-9, "a flat range sits mid-scale");
|
|
}
|
|
|
|
async test_economic_colour_runs_from_yellow_to_blue() {
|
|
const low = economicColour(0, 0, 100);
|
|
const high = economicColour(100, 0, 100);
|
|
this.assertEqual(low, ECONOMIC_GRADIENT[0], "the poorest tile is the first stop");
|
|
this.assertEqual(high, ECONOMIC_GRADIENT[ECONOMIC_GRADIENT.length - 1],
|
|
"the richest tile is the last stop");
|
|
this.assertGreater(luminance(low), luminance(high),
|
|
"the scale darkens as the value rises, so magnitude reads without colour");
|
|
const mid = economicColour(50, 0, 100);
|
|
this.assertTrue(/^#[0-9a-f]{6}$/.test(mid), `a valid colour: ${mid}`);
|
|
}
|
|
|
|
async test_the_population_scale_is_logarithmic() {
|
|
const scale = ECONOMIC_MODES.population.scale;
|
|
this.assertTrue(typeof scale === "function", "population reads through a log scale");
|
|
const linear = economicFraction(100000, 1000, 1000000);
|
|
const logged = economicFraction(100000, 1000, 1000000, scale);
|
|
this.assertGreater(logged, linear, "the log scale lifts smaller towns off the floor");
|
|
// A second MapView mode's figures reach the same scale through the legend.
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
map.setMapMode("population");
|
|
map.setEconomicData("population", new Map(), 1000, 1000000);
|
|
this.assertEqual(map.economicScale, scale, "the active scale reaches the renderer");
|
|
const mid = Math.round(Math.expm1((Math.log1p(1000) + Math.log1p(1000000)) / 2));
|
|
this.assertEqual(
|
|
env.$("#map-legend-mid").text(),
|
|
ECONOMIC_MODES.population.format(mid),
|
|
"the legend's midpoint is the geometric middle"
|
|
);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_every_economic_mode_has_a_label_and_formatter() {
|
|
for (const id of ["gdp", "population"]) {
|
|
const meta = ECONOMIC_MODES[id];
|
|
this.assertNotNull(meta, `${id} is catalogued`);
|
|
this.assertTrue(typeof meta.label === "string" && meta.label.length > 0, "it has a label");
|
|
this.assertTrue(typeof meta.format === "function", "it has a formatter");
|
|
this.assertTrue(meta.format(1234).length > 0, "the formatter returns text");
|
|
}
|
|
}
|
|
|
|
async test_the_economic_legend_names_its_range() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
map.setMapMode("gdp");
|
|
const values = new Map();
|
|
for (const k in map.tiles) values.set(k, 1234);
|
|
map.setEconomicData("gdp", values, 100, 1234);
|
|
this.assertTrue(map.economic, "the GDP mode is active");
|
|
this.assertFalse(map.political, "the political fill is off");
|
|
this.assertFalse(env.$("#map-legend").hasClass("hidden"), "the legend is shown");
|
|
this.assertEqual(env.$("#map-legend-title").text(), "GDP per capita", "the legend is titled");
|
|
this.assertEqual(env.$("#map-legend-min").text(), "¤100", "the legend shows the minimum");
|
|
this.assertEqual(env.$("#map-legend-max").text(), "¤1 234", "the legend shows the maximum");
|
|
// Every land tile carries the top-of-range colour, so the fill is live.
|
|
const top = economicColour(1234, 100, 1234);
|
|
let painted = false;
|
|
env.$("#layer-terrain .hex.terrain").each((_index, element) => {
|
|
const value = element.style.backgroundColor;
|
|
if (value === top || value === cssRgb(top)) painted = true;
|
|
});
|
|
this.assertTrue(painted, "tiles are painted on the colour scale");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_switching_modes_repaints_and_resets_the_legend() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
// Terrain at this zoom is the default plain map.
|
|
this.assertFalse(map.political, "terrain mode at close zoom is not political");
|
|
this.assertFalse(map.economic, "terrain mode is not economic");
|
|
|
|
map.setMapMode("political");
|
|
this.assertTrue(map.political, "political mode forces the political fill");
|
|
this.assertTrue(map.$world.hasClass("political"), "the world is flagged political");
|
|
this.assertTrue(env.$("#map-legend").hasClass("hidden"), "no economic legend");
|
|
|
|
map.setMapMode("population");
|
|
this.assertTrue(map.economic, "population mode is economic");
|
|
this.assertFalse(map.political, "political is off in an economic mode");
|
|
|
|
map.setMapMode("terrain");
|
|
this.assertFalse(map.economic, "terrain clears the economic fill");
|
|
this.assertTrue(env.$("#map-legend").hasClass("hidden"), "the legend is hidden again");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_the_delivery_graph_shows_only_in_economic_modes() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
map.setMapMode("terrain");
|
|
map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
|
|
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("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 {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
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("gdp");
|
|
map.setResourceGraph({
|
|
nodes: [[1, 1, 1]],
|
|
links: [[0, 0, 1, 1, "steel", 10], [2, 2, 3, 3, "food", 5]],
|
|
});
|
|
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");
|
|
this.assertTrue(labels.some((t) => t.includes("Food")), "food is keyed");
|
|
this.assertTrue(labels.some((t) => t.includes("Storage node")), "the node marker is keyed");
|
|
this.assertSize(
|
|
env.$("#map-legend-resources .map-legend-swatch"), 2,
|
|
"one swatch per commodity on the map"
|
|
);
|
|
map.setMapMode("terrain");
|
|
map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
|
|
this.assertTrue(
|
|
env.$("#map-legend-graph").hasClass("hidden"),
|
|
"the key hides off the economic map"
|
|
);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_fog_rebuilds_are_coalesced_between_frames() {
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
let builds = 0;
|
|
map.glRenderer = { buildKnowledge: () => { builds += 1; } };
|
|
map._fogBuiltAt = 0;
|
|
map._fogDirty = false;
|
|
map._refreshFog();
|
|
map._refreshFog();
|
|
map._refreshFog();
|
|
map._flushFog();
|
|
this.assertEqual(builds, 1, "many visibility changes rebuild the fog once");
|
|
|
|
map._refreshFog();
|
|
map._flushFog();
|
|
this.assertEqual(builds, 1, "a later change waits for the interval");
|
|
|
|
map._fogBuiltAt = 0;
|
|
map._flushFog();
|
|
this.assertEqual(builds, 2, "the pending change is flushed once due");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_a_pan_inside_the_fog_cover_does_not_rebuild() {
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
let builds = 0;
|
|
let rows = { start: 10, end: 20 };
|
|
map.glRenderer = {
|
|
_visibleRows: () => rows,
|
|
buildKnowledge: () => { builds += 1; },
|
|
};
|
|
map._fogCover = undefined;
|
|
map._fogBuiltAt = 0;
|
|
map._fogDirty = false;
|
|
|
|
map._flushFog();
|
|
this.assertEqual(builds, 1, "the first flush covers the viewport");
|
|
|
|
// Inside the margin that flush built, so a small pan is free even once
|
|
// the throttle window has passed.
|
|
rows = { start: 12, end: 22 };
|
|
map._fogBuiltAt = 0;
|
|
map._flushFog();
|
|
this.assertEqual(builds, 1, "a pan inside the cover needs no rebuild");
|
|
|
|
// A pan that leaves the built rows must be covered immediately, throttle
|
|
// or not, or the view would show an unfogged hole.
|
|
rows = { start: 60, end: 70 };
|
|
map._fogBuiltAt = performance.now();
|
|
map._flushFog();
|
|
this.assertEqual(builds, 2, "leaving the cover rebuilds at once");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_country_names_sit_below_every_icon_layer() {
|
|
const env = await setupDom();
|
|
try {
|
|
const order = env.$("#map-world .layer").map((_index, element) => element.id).get();
|
|
const names = order.indexOf("layer-political-labels");
|
|
this.assertGreaterOrEqual(names, 0, "the country-name layer exists");
|
|
for (const id of [
|
|
"layer-highlight", "layer-paths", "layer-targets", "layer-entities", "layer-labels",
|
|
]) {
|
|
this.assertLess(names, order.indexOf(id), `${id} paints above the country names`);
|
|
}
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_territory_changes_skip_the_fog_and_region_rebuilds() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
const calls = [];
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
// Install the spy only after the world is built, so it replaces the GL
|
|
// renderer rather than being asked to build the whole world itself.
|
|
map.glRenderer = {
|
|
buildPolitical: () => calls.push("political"),
|
|
buildEconomic: () => calls.push("economic"),
|
|
buildBorders: () => calls.push("borders"),
|
|
buildKnowledge: () => calls.push("fog"),
|
|
buildRegionOverlay: () => calls.push("region"),
|
|
};
|
|
map.political = true;
|
|
calls.length = 0;
|
|
map._applyTerritory([[0, 0, 0], [1, 0, 1]]);
|
|
this.assertTrue(calls.includes("political"), "the political fill follows ownership");
|
|
this.assertFalse(calls.includes("fog"), "a territory change does not rebuild the fog");
|
|
this.assertFalse(calls.includes("region"), "nor the region overlay");
|
|
|
|
calls.length = 0;
|
|
map.mapMode = "terrain";
|
|
map.political = false;
|
|
map.setMapMode("political");
|
|
// A mode change schedules the fog for the frame loop's band rebuild
|
|
// rather than building the whole mesh synchronously.
|
|
this.assertTrue(map._fogDirty, "a mode change schedules a fog rebuild");
|
|
map._flushFog();
|
|
this.assertTrue(calls.includes("fog"), "and the frame loop rebuilds the fog");
|
|
this.assertTrue(calls.includes("region"), "and the region overlay");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_an_escaping_pan_builds_only_the_new_rows() {
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
const ranges = [];
|
|
let rows = { start: 10, end: 20 };
|
|
map.glRenderer = {
|
|
_visibleRows: () => rows,
|
|
buildFogTiles: () => {},
|
|
buildKnowledge: (_view, range) => ranges.push(range ? { ...range } : null),
|
|
};
|
|
map._fogCover = undefined;
|
|
map._fogBuiltAt = 0;
|
|
map._fogDirty = false;
|
|
map._fogChanged = null;
|
|
map._flushFog();
|
|
this.assertSize(ranges, 1, "the first flush builds the band");
|
|
|
|
// A pan that leaves the cover should rebuild only the rows the cover did
|
|
// not already hold, so dragging costs a few rows rather than a screenful.
|
|
rows = { start: 40, end: 50 };
|
|
map._fogBuiltAt = 0;
|
|
map._flushFog();
|
|
this.assertSize(ranges, 2, "leaving the cover builds the new rows");
|
|
this.assertEqual(ranges[1].start, ranges[0].end + 1, "only rows past the old cover");
|
|
this.assertTrue(ranges[1].start > 20 && ranges[1].end >= 50, "the newly exposed rows");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
buildMap(env) {
|
|
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
|
|
map.$viewport.width = () => 800;
|
|
map.$viewport.height = () => 600;
|
|
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
|
|
return map;
|
|
}
|
|
}
|