The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them. The population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph.
488 lines
19 KiB
JavaScript
488 lines
19 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, 0], [3, 3, 0, 0, 40, 1]] });
|
|
this.assertSize(
|
|
env.$("#layer-resources .migration-arrow-head"), 2,
|
|
"one arrowhead per migration flow"
|
|
);
|
|
const movers = env.$("#layer-resources .migrant-mover");
|
|
this.assertSize(movers, 2, "a flagged disc per migration flow");
|
|
this.assertTrue(
|
|
movers.eq(0).css("background-image").includes("flag_france.png"),
|
|
"the origin nation's flag textures the disc"
|
|
);
|
|
this.assertTrue(
|
|
movers.eq(1).css("background-image").includes("flag_britain.png"),
|
|
"a second flow carries its own flag"
|
|
);
|
|
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");
|
|
this.assertTrue(
|
|
labels.some((t) => t.includes("flags show origin")),
|
|
"the key explains the flag discs"
|
|
);
|
|
|
|
// 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");
|
|
this.assertEmpty(env.$("#layer-resources .migrant-mover"), "flag discs hidden too");
|
|
map.setMapMode("gdp");
|
|
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120, 0]] });
|
|
this.assertEmpty(env.$("#layer-resources .migration-arrow-head"), "not on the GDP map");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_the_delivery_graph_has_an_icon_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");
|
|
const icons = env.$("#map-legend-resources .map-legend-icon");
|
|
this.assertSize(icons, 2, "one merchandise icon per commodity on the map");
|
|
this.assertTrue(
|
|
icons.map((_, el) => el.getAttribute("src")).get()
|
|
.some((src) => src.endsWith("icon_steel.svg")),
|
|
"the key shows the merchandise art"
|
|
);
|
|
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_the_delivery_graph_uses_white_lines_and_merchandise_icons() {
|
|
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]],
|
|
});
|
|
const segments = env.$("#layer-resources .border-seg");
|
|
this.assertSize(segments, 2, "one line per delivery leg");
|
|
segments.each((_index, element) => {
|
|
const background = element.style.background || "";
|
|
this.assertTrue(
|
|
/255, 255, 255|#ffffff/i.test(background),
|
|
`the delivery line is white: ${background}`
|
|
);
|
|
});
|
|
const movers = env.$("#layer-resources .resource-mover");
|
|
this.assertSize(movers, 2, "one merchandise icon rides each leg");
|
|
const sources = movers.map((_index, element) => element.getAttribute("src")).get();
|
|
this.assertTrue(sources.some((src) => src.endsWith("icon_steel.svg")), "steel is carried");
|
|
this.assertTrue(sources.some((src) => src.endsWith("icon_food.svg")), "food is carried");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_idle_routes_are_drawn_grey_beneath_the_active_legs() {
|
|
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]],
|
|
edges: [[0, 0, 1, 1], [2, 2, 1, 1], [3, 3, 1, 1]],
|
|
});
|
|
const segments = env.$("#layer-resources .border-seg");
|
|
this.assertSize(segments, 4, "one line per edge and per active leg");
|
|
let white = 0;
|
|
let grey = 0;
|
|
segments.each((_index, element) => {
|
|
const background = element.style.background || "";
|
|
if (/255, 255, 255|#ffffff/i.test(background)) white += 1;
|
|
else if (/123, 127, 133|#7b7f85/i.test(background)) grey += 1;
|
|
});
|
|
this.assertEqual(white, 1, "the active leg is white");
|
|
this.assertEqual(grey, 3, "the idle routes are grey");
|
|
const labels = env.$("#map-legend-resources .map-legend-resource")
|
|
.map((_, el) => env.$(el).text()).get();
|
|
this.assertTrue(labels.some((t) => t.includes("Idle route")), "the key names the idle route");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_the_merchandise_icons_animate_along_the_line() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
const originalNow = performance.now;
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
map.setMapMode("gdp");
|
|
map.setResourceGraph({ nodes: [], links: [[0, 0, 4, 0, "steel", 10]] });
|
|
const mover = env.$("#layer-resources .resource-mover");
|
|
this.assertSize(mover, 1, "an icon rides the leg");
|
|
performance.now = () => 0;
|
|
map._animateDeliveryIcons();
|
|
const first = mover[0].style.left;
|
|
performance.now = () => 650; // a quarter of the 2600 ms cycle
|
|
map._animateDeliveryIcons();
|
|
const second = mover[0].style.left;
|
|
this.assertNotEqual(first, second, "the icon moves over time");
|
|
} finally {
|
|
performance.now = originalNow;
|
|
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;
|
|
}
|
|
}
|