The server sent the whole army on every snapshot and the client re-cloned the whole icon layer whenever a unit moved, so a settled world with a standing army drained the broadcast loop and the frame loop alike. - Ship only the units and city list that changed, keyed by a per-peer fingerprint and a serialised hash, and merge those deltas on the client. - Memoise the battlefield scan on a battle version bumped when a unit joins, leaves or changes side. - Skip the DOM writes for units whose snapshot object is unchanged, and cache the unit-view inner nodes. - Fold units, cities and tile markers onto the nearest world copy instead of mirroring the icon layers, which re-cloned thousands of views every frame; the small decoration layers still mirror, and only on a change. Frame work at full zoom-out with units moving fell from ~190ms to ~6.5ms.
576 lines
23 KiB
JavaScript
576 lines
23 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";
|
|
import { mapToLocal } from "../shared/hex.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\u00a0234", "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_graph_follows_the_repeating_world_across_the_seam() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
map.setMapMode("gdp");
|
|
const { width, originX, originY } = map.topology;
|
|
this.assertTrue(map.topology.cylindrical, "the fixture map wraps");
|
|
const period = map.topology.periodPixels();
|
|
const row = originY + 1;
|
|
const west = originX;
|
|
// The east edge is a neighbour of the west edge over the seam.
|
|
const east = originX + width - 1;
|
|
|
|
map.setResourceGraph({
|
|
nodes: [[west, row, 1], [east, row, 0]],
|
|
links: [[west, row, east, row, "steel", 10]],
|
|
edges: [[west, row, east, row]],
|
|
});
|
|
|
|
// The seam leg is drawn one tile long at any camera, not across the map:
|
|
// the far end snaps to the copy of its neighbour across the seam.
|
|
map.camera.x = mapToLocal(Math.round((west + east) / 2), row).x;
|
|
map.animate(0);
|
|
const segments = env.$("#layer-resources .border-seg");
|
|
this.assertSize(segments, 2, "the idle route and the active leg are both drawn");
|
|
segments.each((_index, el) => {
|
|
this.assertLess(parseFloat(el.style.width), period / 4,
|
|
"the seam leg is redrawn one tile long, not a map period");
|
|
});
|
|
|
|
// Zoomed out the viewport spans more than one period, so the whole world
|
|
// (graph included) is drawn repeated in each copy instead of folded.
|
|
map.camera.zoom = 0.5;
|
|
map.camera.x = 0;
|
|
map.camera.y = 0;
|
|
map.animate(0);
|
|
const offsets = map._visibleWrapOffsets();
|
|
this.assertGreater(offsets.length, 1, "several world copies are on screen");
|
|
const entry = map._wrapCopies.find((e) => e.source.is("#layer-resources"));
|
|
this.assertNotNull(entry, "the resource layer repeats");
|
|
const liveCopy = entry.copies.find((el) => el.style.display !== "none");
|
|
this.assertNotNull(liveCopy, "an extra copy is drawn");
|
|
this.assertGreater(
|
|
liveCopy.querySelectorAll(".border-seg").length, 0, "the delivery legs repeat"
|
|
);
|
|
this.assertGreater(
|
|
liveCopy.querySelectorAll(".resource-node").length, 0, "the storage nodes repeat"
|
|
);
|
|
const matched = /translateX\((-?[\d.]+)px\)/.exec(liveCopy.style.transform);
|
|
this.assertNotNull(matched, `the copy is offset horizontally: ${liveCopy.style.transform}`);
|
|
this.assertEqual(
|
|
Math.abs(parseFloat(matched[1])) % period, 0,
|
|
"the copy sits a whole number of periods from the live one"
|
|
);
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_tile_improvements_fold_with_the_world() {
|
|
const state = smallState();
|
|
const env = await setupDom();
|
|
const map = this.buildMap(env);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
map.setWarfare([], [[0, 0, "mine", 0, 100]], 1);
|
|
// The marker sits at its raw map position while the camera sits over it.
|
|
const marker = env.$("#layer-entities .tile-improvement-marker").eq(0);
|
|
this.assertSize(marker, 1, "the improvement is drawn once");
|
|
this.assertApprox(parseFloat(marker[0].style.left), mapToLocal(0, 0).x, 1e-6,
|
|
"at its raw position, not folded to the camera");
|
|
|
|
// Pan a whole period east: the marker folds onto the copy nearest the
|
|
// camera instead of a clone repeating it in every visible period.
|
|
map.camera.zoom = 0.5;
|
|
map.camera.x = map._period;
|
|
map.camera.y = 0;
|
|
map.animate(0);
|
|
this.assertApprox(parseFloat(marker[0].style.left), mapToLocal(0, 0).x + map._period, 1e-6,
|
|
"the improvement is folded a whole period nearer the camera");
|
|
} 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;
|
|
}
|
|
}
|