Files
Battle-for-Tismo/tests/map_modes_test.js
T
adrien 4b634e4561 Rebuilt the fog of war a tile at a time
Marching into enemy territory changes visibility every step, and each change
rebuilt the whole on-screen fog band. A tile's fog depends only on its own
level and its six neighbours', so the exact dependency radius is one tile, far
tighter than the vision radius.

- Every tile owns a fixed slot in one fog buffer, so rebuilding a tile never
  shifts another's vertices and only the touched row spans are re-uploaded.
  _applyKnowledge now diffs the visible/explored sets and hands the renderer
  the changed tiles; buildFogTiles rebuilds them with their rings. On a 100x100
  map a frontier patch is ~3-4 ms against ~80 ms for the band.
- A pan that leaves the built rows now builds only the newly exposed rows, and
  the escape margin scales with the viewport, so dragging rebuilds rarely.
- _frame schedules the next animation frame before doing any work and catches
  an error, so a one-off failure can no longer stop the frame loop for good.
- Added tests for the per-tile patch and the incremental pan.
2026-09-21 11:06:44 +02:00

297 lines
12 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_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;
}
}