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.
665 lines
28 KiB
JavaScript
665 lines
28 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { GLMapRenderer } from "../client/js/map_view/webgl.js";
|
|
import { CORNERS, EDGE_SEGMENTS, latticeKey } from "../client/js/map_view/distortion.js";
|
|
import { createHeightField } from "../client/js/map_view/heightmap.js";
|
|
import { TERRAIN_LODS } from "../client/js/map_view/constants.js";
|
|
import { MapTopology, key, mapToLocal } from "../shared/hex.js";
|
|
|
|
// Minimal stand-in for a GLMapRenderer: the prototype supplies the pure
|
|
// geometry helpers, and only the two GL touch-points (`_upload`, `this.gl`) are
|
|
// stubbed so the builders can run without a context.
|
|
function makeRenderer() {
|
|
const renderer = Object.create(GLMapRenderer.prototype);
|
|
renderer._uploads = [];
|
|
renderer._subUploads = [];
|
|
renderer.gl = {
|
|
DYNAMIC_DRAW: 0,
|
|
createBuffer: () => ({}),
|
|
bindBuffer: () => {},
|
|
bufferData: (_target, data) => { renderer._uploads.push(data); },
|
|
bufferSubData: (_target, _offset, data) => { renderer._subUploads.push(data); },
|
|
};
|
|
renderer.captured = {};
|
|
renderer.borders = { name: "borders" };
|
|
renderer.roads = { name: "roads" };
|
|
renderer.railways = { name: "railways" };
|
|
renderer.flatTransport = { name: "flatTransport" };
|
|
renderer.regionOverlay = { name: "region" };
|
|
renderer.politicalFill = { name: "political" };
|
|
renderer.economicFill = { name: "economic" };
|
|
renderer._upload = function (pass, arr, usage, floats) {
|
|
this.captured[pass.name] = arr;
|
|
pass.floats = floats;
|
|
pass.count = arr.length / (floats || 8);
|
|
};
|
|
return renderer;
|
|
}
|
|
|
|
function view(renderer, { size = 3, territory, regions, selectedRegion = null, roads = new Set(), railways = new Set(), terrainAt = null } = {}) {
|
|
const topology = new MapTopology({ x: size, y: size }, false);
|
|
const tiles = {};
|
|
for (let y = topology.originY; y < topology.originY + size; y++) {
|
|
for (let x = topology.originX; x < topology.originX + size; x++) {
|
|
tiles[key(x, y)] = { col: 0, terrainType: terrainAt ? terrainAt(x, y) : "Land" };
|
|
}
|
|
}
|
|
return {
|
|
seed: 4242,
|
|
topology,
|
|
tiles,
|
|
roads,
|
|
railways,
|
|
territory: new Map(territory),
|
|
regions: new Map(regions),
|
|
civilisations: [{ primaryColour: "#ff0000" }, { primaryColour: "#00ff00" }],
|
|
_selectedRegion: selectedRegion,
|
|
};
|
|
}
|
|
|
|
export class WebglGeometryTest extends TestCase {
|
|
async test_chains_stitch_edges_across_tiles() {
|
|
const renderer = makeRenderer();
|
|
const edges = [
|
|
{ points: [{ x: 0, y: 0 }, { x: 1, y: 0 }], startKey: "0", endKey: "1" },
|
|
{ points: [{ x: 1, y: 0 }, { x: 2, y: 0 }], startKey: "1", endKey: "2" },
|
|
{ points: [{ x: 9, y: 9 }, { x: 8, y: 9 }], startKey: "8", endKey: "7" },
|
|
];
|
|
const chains = renderer._chainEdges(edges);
|
|
this.assertSize(chains, 2, "two disjoint runs");
|
|
const run = chains.find((chain) => chain.points.length === 3);
|
|
this.assertNotNull(run, "the joined run has three points");
|
|
this.assertFalse(run.closed, "a run with two open ends is not closed");
|
|
this.assertEqual(run.points[0], { x: 0, y: 0 }, "the shared corner is only listed once");
|
|
}
|
|
|
|
async test_closed_boundary_is_one_chain() {
|
|
const renderer = makeRenderer();
|
|
const edges = [
|
|
{ points: [{ x: 0, y: 0 }, { x: 1, y: 0 }], startKey: "0", endKey: "1" },
|
|
{ points: [{ x: 1, y: 0 }, { x: 1, y: 1 }], startKey: "1", endKey: "2" },
|
|
{ points: [{ x: 1, y: 1 }, { x: 0, y: 0 }], startKey: "2", endKey: "0" },
|
|
];
|
|
const chains = renderer._chainEdges(edges);
|
|
this.assertSize(chains, 1, "the loop is a single chain");
|
|
this.assertTrue(chains[0].closed, "the loop closes");
|
|
this.assertSize(chains[0].points, 3, "the repeated closing point is dropped");
|
|
}
|
|
|
|
async test_border_strokes_are_finite() {
|
|
const renderer = makeRenderer();
|
|
const territory = [
|
|
["-1,-1", 0], ["0,-1", 0], ["-1,0", 0], ["0,0", 0],
|
|
["1,0", 1], ["1,1", 1],
|
|
];
|
|
const state = view(renderer, { territory });
|
|
renderer.buildBorders(state);
|
|
const arr = renderer.captured.borders;
|
|
this.assertGreater(arr.length, 0, "borders are built");
|
|
for (const value of arr) {
|
|
this.assertTrue(Number.isFinite(value), "every vertex coordinate is finite");
|
|
}
|
|
}
|
|
|
|
async test_a_nations_whole_outline_is_one_closed_chain() {
|
|
// A 3x3 block owned by a single nation has no open ends, so its border must
|
|
// chain into one loop rather than a handful of per-tile strokes.
|
|
const renderer = makeRenderer();
|
|
const territory = [];
|
|
for (let y = -1; y <= 1; y++) {
|
|
for (let x = -1; x <= 1; x++) territory.push([key(x, y), 0]);
|
|
}
|
|
const state = view(renderer, { territory });
|
|
const edges = [];
|
|
for (const [k, owner] of state.territory) {
|
|
const coords = k.split(",").map(Number);
|
|
const centre = mapToLocal(coords[0], coords[1]);
|
|
const neighbours = renderer._edgeNeighbours(state, coords[0], coords[1]);
|
|
for (let e = 0; e < CORNERS.length; e++) {
|
|
const neighbour = neighbours[e];
|
|
if (!neighbour) continue;
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
if (state.territory.get(nk) === owner) continue;
|
|
const a = { x: centre.x + CORNERS[e].x, y: centre.y + CORNERS[e].y };
|
|
const next = CORNERS[(e + 1) % CORNERS.length];
|
|
const b = { x: centre.x + next.x, y: centre.y + next.y };
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const mx = (a.x + b.x) / 2;
|
|
const my = (a.y + b.y) / 2;
|
|
const ownerIsLeft = (centre.x - mx) * -dy + (centre.y - my) * dx >= 0;
|
|
edges.push(ownerIsLeft
|
|
? { points: [a, b], startKey: latticeKey(a.x, a.y), endKey: latticeKey(b.x, b.y) }
|
|
: { points: [b, a], startKey: latticeKey(b.x, b.y), endKey: latticeKey(a.x, a.y) });
|
|
}
|
|
}
|
|
const chains = renderer._chainEdges(edges);
|
|
this.assertSize(chains, 1, "the whole coastline is one run");
|
|
this.assertTrue(chains[0].closed, "the block's border wraps all the way round");
|
|
}
|
|
|
|
async test_region_border_is_suppressed_on_a_national_border() {
|
|
const selected = 0;
|
|
const sharedDifferentOwner = [
|
|
["0,0", 0], ["1,0", 1],
|
|
];
|
|
const sharedSameOwner = [
|
|
["0,0", 0], ["1,0", 0],
|
|
];
|
|
const regions = [["0,0", 0], ["1,0", 1]];
|
|
|
|
const splitRenderer = makeRenderer();
|
|
splitRenderer.buildRegionOverlay(
|
|
view(splitRenderer, { territory: sharedDifferentOwner, regions, selectedRegion: selected })
|
|
);
|
|
const split = splitRenderer.captured.region.length;
|
|
|
|
const joinedRenderer = makeRenderer();
|
|
joinedRenderer.buildRegionOverlay(
|
|
view(joinedRenderer, { territory: sharedSameOwner, regions, selectedRegion: selected })
|
|
);
|
|
const joined = joinedRenderer.captured.region.length;
|
|
|
|
// Only the selected tile (0,0) contributes; its six-edge fill is 24 tris.
|
|
const fill = 24 * 3 * 8;
|
|
this.assertEqual(split, fill, "no dotted line is drawn over the national border");
|
|
this.assertGreater(joined, fill, "a same-owner region border is still dotted");
|
|
}
|
|
|
|
async test_roads_join_at_a_bend_without_a_notch() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, { roads: new Set(["-1,0", "0,0", "1,0"]) });
|
|
renderer.buildRoads(state);
|
|
const arr = renderer.captured.roads;
|
|
this.assertGreater(arr.length, 0, "roads are built");
|
|
for (const value of arr) {
|
|
this.assertTrue(Number.isFinite(value), "every road vertex is finite");
|
|
}
|
|
}
|
|
|
|
async test_terrain_vertices_carry_the_relief() {
|
|
const renderer = makeRenderer();
|
|
let captured = null;
|
|
renderer._upload = (pass, arr, usage, floats) => {
|
|
captured = arr;
|
|
pass.floats = floats;
|
|
};
|
|
const state = view(renderer, {
|
|
terrainAt: (x, y) => (x === 0 && y === 0 ? "Mountain" : "Land"),
|
|
});
|
|
renderer._buildTerrainPass(state, 0);
|
|
this.assertEqual(captured.length % 17, 0, "seventeen floats per terrain vertex");
|
|
let max = 0;
|
|
for (let i = 0; i < captured.length; i += 17) {
|
|
const height = captured[i + 6];
|
|
this.assertTrue(Number.isFinite(height), "height is finite");
|
|
this.assertTrue(height >= 0 && height <= 1, "height is normalised");
|
|
this.assertTrue(Number.isFinite(captured[i + 15]), "slope x is finite");
|
|
this.assertTrue(Number.isFinite(captured[i + 16]), "slope y is finite");
|
|
max = Math.max(max, height);
|
|
}
|
|
this.assertGreater(max, 0.4, "the mountain lifts its vertices high");
|
|
}
|
|
|
|
async test_a_coarser_level_of_detail_uses_fewer_vertices() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer);
|
|
let counts = [];
|
|
renderer._upload = (pass, arr) => { counts.push(arr.length); };
|
|
renderer._buildTerrainPass(state, 0);
|
|
renderer._buildTerrainPass(state, 2);
|
|
this.assertGreater(counts[0], counts[1], "the far level has less geometry");
|
|
}
|
|
|
|
async test_the_closest_level_subdivides_each_fan_triangle() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer);
|
|
let verts = 0;
|
|
renderer._upload = (pass, arr) => { verts = arr.length / 17; };
|
|
renderer._buildTerrainPass(state, 0);
|
|
const lod = TERRAIN_LODS[0];
|
|
const trianglesPerTile = CORNERS.length * lod.segments * 4 ** (lod.subdivisions || 0);
|
|
this.assertEqual(verts, 9 * trianglesPerTile * 3, "each fan triangle is split in four");
|
|
}
|
|
|
|
async test_linked_road_tiles_are_bands_not_straight_caps() {
|
|
// A lone road tile is a single textured cap; adjacent tiles are drawn as a
|
|
// continuous band with no straight tile stamped at the junction.
|
|
const renderer = makeRenderer();
|
|
renderer.buildRoads(view(renderer, { roads: new Set(["0,0"]) }));
|
|
const lone = renderer.captured.roads.length;
|
|
this.assertEqual(lone % 9, 0, "nine floats per transport vertex");
|
|
this.assertEqual(lone, 6 * 9, "a lone tile is one quad");
|
|
|
|
const paired = makeRenderer();
|
|
paired.buildRoads(view(paired, { roads: new Set(["0,0", "1,0"]) }));
|
|
const link = paired.captured.roads.length;
|
|
this.assertEqual(link % 9, 0, "nine floats per transport vertex");
|
|
this.assertGreater(link, lone, "the link is a band, not caps");
|
|
}
|
|
|
|
async test_road_and_railway_halves_meet_at_a_mixed_link() {
|
|
const renderer = makeRenderer();
|
|
renderer.buildRoads(view(renderer, {
|
|
roads: new Set(["0,0"]),
|
|
railways: new Set(["1,0"]),
|
|
}));
|
|
const road = renderer.captured.roads;
|
|
const rail = renderer.captured.railways;
|
|
this.assertGreater(road.length, 0, "the road half is drawn");
|
|
this.assertGreater(rail.length, 0, "the rail half is drawn");
|
|
const pointKeys = (arr) => {
|
|
const set = new Set();
|
|
for (let i = 0; i < arr.length; i += 9) {
|
|
set.add(`${arr[i].toFixed(4)},${arr[i + 1].toFixed(4)}`);
|
|
}
|
|
return set;
|
|
};
|
|
const roadPoints = pointKeys(road);
|
|
let shared = 0;
|
|
for (const k of pointKeys(rail)) if (roadPoints.has(k)) shared += 1;
|
|
this.assertGreater(shared, 0, "both halves share the border vertices");
|
|
}
|
|
|
|
async test_flat_transport_is_solid_straight_strokes() {
|
|
const renderer = makeRenderer();
|
|
renderer.buildRoads(view(renderer, {
|
|
roads: new Set(["0,0", "1,0"]),
|
|
railways: new Set(["-1,0"]),
|
|
}));
|
|
const flat = renderer.captured.flatTransport;
|
|
this.assertGreater(flat.length, 0, "the flat transport is built");
|
|
this.assertEqual(flat.length % 8, 0, "eight floats per flat vertex");
|
|
const colours = new Set();
|
|
for (let i = 0; i < flat.length; i += 8) {
|
|
colours.add(flat[i + 4].toFixed(4));
|
|
this.assertEqual(flat[i + 2], 0, "the flat map ignores the relief");
|
|
}
|
|
this.assertTrue(colours.has((0x3a / 255).toFixed(4)), "roads are dark grey");
|
|
this.assertTrue(colours.has((0x8a / 255).toFixed(4)), "railways are brown");
|
|
}
|
|
|
|
async test_a_bent_road_is_a_curve_not_straight_stubs() {
|
|
const renderer = makeRenderer();
|
|
const topo = new MapTopology({ x: 5, y: 5 }, false);
|
|
const a = { x: 0, y: 0 };
|
|
const b = { x: 1, y: 0 };
|
|
const A = mapToLocal(a.x, a.y);
|
|
const B = mapToLocal(b.x, b.y);
|
|
// The neighbour of b that bends the most away from the a->b line.
|
|
let bend = null;
|
|
let bestDeviation = -1;
|
|
for (const n of topo.neighbours(b.x, b.y)) {
|
|
if (n.x === a.x && n.y === a.y) continue;
|
|
const C = mapToLocal(n.x, n.y);
|
|
const dx = B.x - A.x;
|
|
const dy = B.y - A.y;
|
|
const deviation = Math.abs((C.x - A.x) * -dy + (C.y - A.y) * dx) / Math.hypot(dx, dy);
|
|
if (deviation > bestDeviation) {
|
|
bestDeviation = deviation;
|
|
bend = n;
|
|
}
|
|
}
|
|
this.assertGreater(bestDeviation, 5, "the map has a real bend");
|
|
const roads = new Set([key(a.x, a.y), key(b.x, b.y), key(bend.x, bend.y)]);
|
|
renderer.buildRoads(view(renderer, { size: 5, roads }));
|
|
const arr = renderer.captured.roads;
|
|
this.assertGreater(arr.length, 0, "the bend is drawn");
|
|
|
|
// Recover the band's centreline from each quad: corner0/corner3 average to
|
|
// the link's start centre and corner1/corner2 to its end centre.
|
|
const distanceToSegment = (p, s, e) => {
|
|
const dx = e.x - s.x;
|
|
const dy = e.y - s.y;
|
|
const len2 = dx * dx + dy * dy || 1;
|
|
let t = ((p.x - s.x) * dx + (p.y - s.y) * dy) / len2;
|
|
t = Math.max(0, Math.min(1, t));
|
|
return Math.hypot(p.x - (s.x + dx * t), p.y - (s.y + dy * t));
|
|
};
|
|
const C = mapToLocal(bend.x, bend.y);
|
|
let worst = 0;
|
|
for (let i = 0; i + 45 <= arr.length; i += 54) {
|
|
const p = { x: (arr[i] + arr[i + 45]) / 2, y: (arr[i + 1] + arr[i + 46]) / 2 };
|
|
const q = { x: (arr[i + 9] + arr[i + 18]) / 2, y: (arr[i + 10] + arr[i + 19]) / 2 };
|
|
for (const point of [p, q]) {
|
|
const off = Math.min(
|
|
distanceToSegment(point, A, B),
|
|
distanceToSegment(point, B, C)
|
|
);
|
|
worst = Math.max(worst, off);
|
|
}
|
|
}
|
|
this.assertGreater(worst, 1, "the centreline bows off the straight corners");
|
|
}
|
|
|
|
async test_road_continuation_uses_the_opposite_neighbour() {
|
|
const renderer = makeRenderer();
|
|
const coordsOf = new Map([
|
|
["a", { x: 0, y: 0 }],
|
|
["b", { x: 1, y: 0 }],
|
|
["c", { x: 2, y: 0 }],
|
|
]);
|
|
const topo = { pixelDelta: (from, to) => ({ x: to.x - from.x, y: to.y - from.y }) };
|
|
this.assertEqual(
|
|
renderer._continuationKey(["a", "b", "c"], "b", "c", coordsOf, topo),
|
|
"a",
|
|
"continues through the tile behind"
|
|
);
|
|
this.assertNull(
|
|
renderer._continuationKey(["c"], "b", "c", coordsOf, topo),
|
|
"a dead end has no continuation"
|
|
);
|
|
}
|
|
|
|
async test_seam_road_links_stay_local_and_do_not_span_the_world() {
|
|
const renderer = makeRenderer();
|
|
const topology = new MapTopology({ x: 20, y: 6 }, true);
|
|
const a = { x: topology.originX, y: 0 };
|
|
const A = mapToLocal(a.x, a.y);
|
|
// The neighbour that lies on the far side of the wrap: raw mapToLocal puts
|
|
// it a whole world away, but pixelDelta knows it is one step.
|
|
let wrap = null;
|
|
let rawDistance = 0;
|
|
for (const n of topology.neighbours(a.x, a.y)) {
|
|
const d = Math.hypot(mapToLocal(n.x, n.y).x - A.x, mapToLocal(n.x, n.y).y - A.y);
|
|
if (d > rawDistance) {
|
|
rawDistance = d;
|
|
wrap = n;
|
|
}
|
|
}
|
|
this.assertGreater(rawDistance, 400, "a neighbour wraps the seam");
|
|
const tiles = {};
|
|
for (let y = topology.originY; y < topology.originY + 6; y++) {
|
|
for (let x = topology.originX; x < topology.originX + 20; x++) {
|
|
tiles[key(x, y)] = { col: 0, terrainType: "Land" };
|
|
}
|
|
}
|
|
renderer.buildRoads({
|
|
seed: 1,
|
|
topology,
|
|
tiles,
|
|
roads: new Set([key(a.x, a.y), key(wrap.x, wrap.y)]),
|
|
territory: new Map(),
|
|
regions: new Map(),
|
|
civilisations: [{ primaryColour: "#ff0000" }],
|
|
});
|
|
const arr = renderer.captured.roads;
|
|
this.assertGreater(arr.length, 0, "the seam link is drawn");
|
|
const xs = [];
|
|
for (let i = 0; i < arr.length; i += 9) xs.push(arr[i]);
|
|
this.assertLess(Math.max(...xs) - Math.min(...xs), 200, "the link stays at the seam");
|
|
}
|
|
|
|
async test_flat_layers_carry_the_relief_too() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, { roads: new Set(["0,0"]) });
|
|
renderer.buildRoads(state);
|
|
const arr = renderer.captured.roads;
|
|
let lifted = false;
|
|
for (let i = 0; i < arr.length; i += 9) {
|
|
if (arr[i + 4] > 0) lifted = true;
|
|
}
|
|
this.assertTrue(lifted, "road vertices ride the heightmap");
|
|
}
|
|
|
|
async test_sea_tiles_are_left_out_of_the_terrain_mesh() {
|
|
// The sea is drawn by the single ocean quad, so no terrain vertex may still
|
|
// carry the water flag (the terrain layout puts it at index 10).
|
|
const renderer = makeRenderer();
|
|
let captured = null;
|
|
renderer._upload = (pass, arr, usage, floats) => {
|
|
captured = arr;
|
|
pass.floats = floats;
|
|
};
|
|
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Land" : "Sea") });
|
|
renderer._buildTerrainPass(state, 0);
|
|
this.assertGreater(captured.length, 0, "the land tiles are still built");
|
|
for (let i = 0; i < captured.length; i += 17) {
|
|
this.assertEqual(captured[i + 10], 0, "no sea tile is drawn as water");
|
|
}
|
|
}
|
|
|
|
async test_the_ocean_is_one_flat_quad() {
|
|
const renderer = makeRenderer();
|
|
renderer.ocean = { name: "ocean" };
|
|
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Land" : "Sea") });
|
|
state._worldMinX = -31.5;
|
|
state._worldMinY = -56;
|
|
state._worldWidth = 100;
|
|
state._worldHeight = 80;
|
|
renderer.buildOcean(state);
|
|
const arr = renderer.captured.ocean;
|
|
this.assertEqual(arr.length, 6 * 17, "one quad is two triangles");
|
|
for (let i = 0; i < arr.length; i += 17) {
|
|
this.assertEqual(arr[i + 6], 0, "the ocean surface is flat");
|
|
this.assertEqual(arr[i + 10], 1, "the water flag is set");
|
|
this.assertEqual(arr[i + 15], 0, "the ocean has no slope");
|
|
this.assertEqual(arr[i + 16], 0, "the ocean has no slope");
|
|
}
|
|
}
|
|
|
|
async test_border_strokes_ride_the_relief() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, {
|
|
territory: [["0,0", 0], ["1,0", 1]],
|
|
terrainAt: (x) => (x === 0 ? "Mountain" : "Land"),
|
|
});
|
|
renderer.buildBorders(state);
|
|
const arr = renderer.captured.borders;
|
|
let lifted = false;
|
|
for (let i = 0; i < arr.length; i += 8) {
|
|
if (arr[i + 2] > 0) lifted = true;
|
|
}
|
|
this.assertTrue(lifted, "border vertices ride the heightmap");
|
|
}
|
|
|
|
async test_political_and_economic_borders_are_flat() {
|
|
for (const mode of ["political", "economic"]) {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, {
|
|
territory: [["0,0", 0], ["1,0", 1]],
|
|
terrainAt: (x) => (x === 0 ? "Mountain" : "Land"),
|
|
});
|
|
state[mode] = true;
|
|
renderer.buildBorders(state);
|
|
const arr = renderer.captured.borders;
|
|
this.assertGreater(arr.length, 0, `${mode} borders are built`);
|
|
for (let i = 0; i < arr.length; i += 8) {
|
|
this.assertEqual(arr[i + 2], 0, `${mode} borders ignore the relief`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async test_the_political_fill_paints_owned_tiles_only() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, { territory: [["0,0", 0], ["1,0", 1]] });
|
|
renderer._warp = renderer._buildWarpSet(state);
|
|
renderer.buildPolitical(state);
|
|
this.assertEmpty(renderer.captured.political, "the terrain map has no political fill");
|
|
|
|
state.political = true;
|
|
renderer.buildPolitical(state);
|
|
const arr = renderer.captured.political;
|
|
// The 3x3 map is all land, so every tile is painted: owned tiles in their
|
|
// country's colour, the rest in the neutral land colour. Six triangles and
|
|
// 8 floats a vertex.
|
|
this.assertEqual(arr.length, 9 * 6 * 3 * 8, "every land tile is painted");
|
|
const colours = new Set();
|
|
for (let i = 0; i < arr.length; i += 8) {
|
|
colours.add([arr[i + 4], arr[i + 5], arr[i + 6]].join(","));
|
|
this.assertTrue(Number.isFinite(arr[i + 2]), "every political vertex is finite");
|
|
this.assertEqual(arr[i + 2], 0, "the political fill is flat");
|
|
}
|
|
this.assertTrue(colours.has([1, 0, 0].join(",")), "an owned tile uses its country colour");
|
|
this.assertTrue(colours.size >= 2, "unclaimed land is a second colour");
|
|
}
|
|
|
|
async test_the_economic_fill_paints_every_valued_tile() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer);
|
|
renderer._warp = renderer._buildWarpSet(state);
|
|
renderer.buildEconomic(state);
|
|
this.assertEmpty(renderer.captured.economic, "no figures means no economic fill");
|
|
|
|
state.economic = true;
|
|
state.economicValues = new Map([["0,0", 10], ["1,0", 20], ["0,1", 30]]);
|
|
state.economicRange = { min: 0, max: 30 };
|
|
renderer.buildEconomic(state);
|
|
const arr = renderer.captured.economic;
|
|
this.assertEqual(arr.length, 3 * 6 * 3 * 8, "one flat hexagon per valued tile");
|
|
for (const value of arr) {
|
|
this.assertTrue(Number.isFinite(value), "every economic vertex is finite");
|
|
}
|
|
for (let i = 0; i < arr.length; i += 8) {
|
|
this.assertEqual(arr[i + 2], 0, "the economic fill is flat");
|
|
}
|
|
}
|
|
|
|
async test_sea_edges_are_straight_except_against_land() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Sea" : "Land") });
|
|
const wavy = renderer.edgeWaviness(state, 0, 0);
|
|
this.assertSize(wavy, 6, "one flag per edge");
|
|
this.assertTrue(wavy.some((flag) => flag === false), "open-water edges are straight");
|
|
this.assertTrue(wavy.some((flag) => flag === true), "coastal edges stay wavy");
|
|
this.assertNull(renderer.edgeWaviness(state, 1, 0), "land tiles keep the all-wavy default");
|
|
}
|
|
|
|
async test_a_ridge_in_front_hides_a_border_point() {
|
|
const renderer = makeRenderer();
|
|
const flat = { at: () => 0.1 };
|
|
this.assertFalse(renderer._isOccluded(flat, 0, 0), "flat ground hides nothing");
|
|
const ridge = { at: (x, y) => (y > 0 ? 0.9 : 0.1) };
|
|
this.assertTrue(renderer._isOccluded(ridge, 0, 0), "a rise to the south hides the point");
|
|
const gentle = { at: (x, y) => (y > 0 ? 0.15 : 0.1) };
|
|
this.assertFalse(renderer._isOccluded(gentle, 0, 0), "a gentle rise does not");
|
|
}
|
|
|
|
async test_is_occluded_reads_the_relief_field() {
|
|
const renderer = makeRenderer();
|
|
this.assertFalse(renderer.isOccluded(0, 0), "no field hides nothing");
|
|
renderer.heightField = { at: (x, y) => (y > 0 ? 0.9 : 0.1) };
|
|
this.assertTrue(renderer.isOccluded(0, 0), "a ridge to the south hides the point");
|
|
}
|
|
|
|
async test_hidden_border_runs_are_dotted() {
|
|
const renderer = makeRenderer();
|
|
const line = [{ x: 0, y: 0, h: 0 }, { x: 100, y: 0, h: 0 }];
|
|
const solid = [];
|
|
renderer._stroke(solid, line, 3, [0, 0, 0, 1], false);
|
|
const dotted = [];
|
|
renderer._stroke(dotted, line, 3, [0, 0, 0, 1], false, [true, true]);
|
|
this.assertGreater(dotted.length, solid.length, "a hidden run breaks into dashes");
|
|
this.assertEqual(dotted.length % 8, 0, "flat-colour vertices");
|
|
}
|
|
|
|
async test_point_height_reads_the_relief_field() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, {
|
|
terrainAt: (x, y) => (x === 0 && y === 0 ? "Mountain" : "Land"),
|
|
});
|
|
this.assertEqual(renderer.pointHeight(0, 0), 0, "no field means flat");
|
|
renderer.heightField = createHeightField(state);
|
|
const centre = mapToLocal(0, 0);
|
|
this.assertGreater(renderer.pointHeight(centre.x, centre.y), 0.3, "the mountain lifts its centre");
|
|
}
|
|
|
|
async test_open_water_hexagons_stay_regular() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, { terrainAt: () => "Sea" });
|
|
renderer._warp = renderer._buildWarpSet(state);
|
|
this.assertSize(renderer._warp, 0, "open water has no warped vertices");
|
|
const centre = mapToLocal(0, 0);
|
|
const boundary = renderer.tileBoundary(state, 0, 0);
|
|
for (let i = 0; i < CORNERS.length; i++) {
|
|
const point = boundary[i * EDGE_SEGMENTS];
|
|
this.assertApprox(point.x, centre.x + CORNERS[i].x, 1e-9, "regular corner x");
|
|
this.assertApprox(point.y, centre.y + CORNERS[i].y, 1e-9, "regular corner y");
|
|
}
|
|
}
|
|
|
|
async test_land_vertices_are_still_warped() {
|
|
const renderer = makeRenderer();
|
|
const state = view(renderer, { terrainAt: (x) => (x === 0 ? "Land" : "Sea") });
|
|
renderer._warp = renderer._buildWarpSet(state);
|
|
this.assertGreater(renderer._warp.size, 0, "land vertices are warped");
|
|
const centre = mapToLocal(0, 0);
|
|
const boundary = renderer.tileBoundary(state, 0, 0);
|
|
let moved = false;
|
|
for (let i = 0; i < CORNERS.length; i++) {
|
|
const point = boundary[i * EDGE_SEGMENTS];
|
|
const ideal = { x: centre.x + CORNERS[i].x, y: centre.y + CORNERS[i].y };
|
|
if (Math.hypot(point.x - ideal.x, point.y - ideal.y) > 1e-6) moved = true;
|
|
}
|
|
this.assertTrue(moved, "a land tile keeps its warped outline");
|
|
}
|
|
|
|
async test_fog_build_is_a_noop_before_the_world_exists() {
|
|
const renderer = makeRenderer();
|
|
renderer.fog = { name: "fog" };
|
|
renderer._warp = null;
|
|
// The frame loop runs before the first snapshot has built the world, so
|
|
// there is no topology yet. This must not throw, or the exception escapes
|
|
// `animate` and the animation loop never schedules another frame.
|
|
renderer.buildKnowledge({ seed: 1, tiles: {} });
|
|
this.assertEqual(renderer.captured.fog.length, 0, "no fog is built without a topology");
|
|
this.assertEqual(renderer.fog.rowStart, null, "the row table is cleared");
|
|
this.assertEqual(renderer.fog.bandStart, undefined, "no band is claimed");
|
|
}
|
|
|
|
async test_fog_builds_only_the_requested_rows() {
|
|
const renderer = makeRenderer();
|
|
renderer.fog = { name: "fog" };
|
|
renderer._warp = null;
|
|
const state = view(renderer, { size: 6 });
|
|
// Explored-but-not-visible ground is fogged, so every tile contributes.
|
|
state.explored = new Set(Object.keys(state.tiles));
|
|
state.visible = new Set();
|
|
|
|
renderer.buildKnowledge(state);
|
|
const slot = renderer._fogSlot;
|
|
const rowFloats = renderer._fogRowFloats;
|
|
this.assertGreater(slot, 0, "a tile owns a fixed slot");
|
|
this.assertEqual(renderer._fogBuffer.length, 6 * 6 * slot, "the buffer holds every tile");
|
|
|
|
// A band build rewrites only its rows and re-uploads only those rows.
|
|
renderer._fogBuffer.fill(-1);
|
|
renderer._subUploads.length = 0;
|
|
renderer.buildKnowledge(state, { start: 1, end: 2 });
|
|
const row0 = renderer._fogBuffer.subarray(0, rowFloats);
|
|
const row1 = renderer._fogBuffer.subarray(1 * rowFloats, 2 * rowFloats);
|
|
this.assertTrue(row0.every((v) => v === -1), "rows outside the band are untouched");
|
|
this.assertTrue(row1.some((v) => v > 0), "the requested rows are rebuilt");
|
|
this.assertTrue(renderer._subUploads.length > 0, "only the rebuilt rows are re-uploaded");
|
|
// The row table stays full length with fixed offsets the draw can index.
|
|
this.assertEqual(renderer.fog.rowStart.length, state.topology.height + 1);
|
|
this.assertEqual(renderer.fog.rowStart[0], 0, "row zero starts at zero");
|
|
this.assertEqual(renderer.fog.rowStart[1], rowFloats / 7, "rows carry fixed offsets");
|
|
}
|
|
|
|
async test_fog_patches_only_the_changed_tiles() {
|
|
const renderer = makeRenderer();
|
|
renderer.fog = { name: "fog" };
|
|
renderer._warp = null;
|
|
const state = view(renderer, { size: 6 });
|
|
state.explored = new Set(Object.keys(state.tiles));
|
|
state.visible = new Set();
|
|
renderer.buildKnowledge(state);
|
|
const slot = renderer._fogSlot;
|
|
const before = Float32Array.from(renderer._fogBuffer);
|
|
const index = (r, c) => (r * 6 + c) * slot;
|
|
const revealed = key(state.topology.originX + 3, state.topology.originY + 3);
|
|
state.visible = new Set([revealed]);
|
|
renderer.buildFogTiles(state, new Set([revealed]));
|
|
this.assertTrue(
|
|
renderer._fogBuffer.subarray(index(3, 3), index(3, 3) + slot)
|
|
.some((v, i) => v !== before[index(3, 3) + i]),
|
|
"the revealed tile is rebuilt"
|
|
);
|
|
this.assertTrue(
|
|
renderer._fogBuffer.subarray(index(0, 0), index(0, 0) + slot)
|
|
.every((v, i) => v === before[index(0, 0) + i]),
|
|
"a distant tile is left alone"
|
|
);
|
|
}
|
|
}
|