Files
Battle-for-Tismo/tests/political_map_test.js
adrien 3e429ccb64 Gave production buildings their own economy and fed island regions
Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply.

Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory.

Bundles the in-progress tree cleanup that was already present in the working tree.
2026-09-22 14:34:14 +02:00

253 lines
11 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 { MapTopology, key } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
import {
countryLabels,
fitLabelBand,
fitTextWidth,
territoryComponents,
MAX_KERNING,
MAX_LABEL_ANGLE,
MIN_FONT,
} from "../client/js/map_view/political.js";
import { smallState, mapLayers } from "./framework/helpers.js";
const CIVS = [
{ id: "a", name: "France", primaryColour: "#123456" },
{ id: "b", name: "Britain", primaryColour: "#abcdef" },
];
function territoryOf(entries) {
return new Map(entries.map(([x, y, civ]) => [key(x, y), civ]));
}
export class PoliticalMapTest extends TestCase {
async test_components_split_by_owner_and_contiguity() {
const topo = new MapTopology({ x: 8, y: 8 }, false);
const territory = territoryOf([
[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0],
[5, 5, 0],
[0, 3, 1], [1, 3, 1],
]);
const components = territoryComponents(territory, topo);
this.assertSize(components, 3, "one holding per contiguous run and owner");
const sizes = components.map((c) => c.points.length).sort((a, b) => a - b);
this.assertEqual(sizes, [1, 2, 4], "each holding keeps its own tiles");
for (const component of components) {
this.assertGreater(component.points.length, 0, "every holding has points");
}
}
async test_a_holding_across_the_wrap_is_unwrapped() {
const topo = new MapTopology({ x: 8, y: 4 }, true);
// On a width-8 cylindrical world the columns are -4..3, so 3 and -4 are the
// two sides of the seam and are neighbours.
const territory = territoryOf([[3, 0, 0], [-4, 0, 0], [-4, 1, 0]]);
const components = territoryComponents(territory, topo);
this.assertSize(components, 1, "the seam does not split the holding");
const points = components[0].points;
let span = 0;
for (const a of points) {
for (const b of points) span = Math.max(span, Math.hypot(a.x - b.x, a.y - b.y));
}
this.assertLess(span, 4 * 63, "the wrapped tiles are placed side by side");
}
async test_a_wide_holding_is_labelled_horizontally() {
const topo = new MapTopology({ x: 10, y: 8 }, false);
const entries = [];
for (let y = 0; y < 2; y++) {
for (let x = 0; x < 6; x++) entries.push([x, y, 0]);
}
const band = fitLabelBand(territoryComponents(territoryOf(entries), topo)[0].points);
this.assertNotNull(band, "a block gets a band");
this.assertLess(Math.abs(band.angle), 0.35, "the long axis is horizontal");
this.assertGreater(band.fontSize, 0, "the text has a size");
this.assertGreater(band.maxLength, 0, "the text has a length");
}
async test_a_tall_holding_is_never_rotated_far_from_horizontal() {
const topo = new MapTopology({ x: 8, y: 10 }, false);
const entries = [];
for (let y = 0; y < 6; y++) {
for (let x = 0; x < 2; x++) entries.push([x, y, 0]);
}
const band = fitLabelBand(territoryComponents(territoryOf(entries), topo)[0].points);
this.assertNotNull(band, "a tall block gets a band");
this.assertLess(Math.abs(band.angle), MAX_LABEL_ANGLE + 1e-6,
"the base angle stays inside the readable range");
}
async test_text_is_never_compressed_and_kerning_is_capped() {
// A run already wider than the band shrinks the font instead of squeezing
// the letters together.
const shrunk = fitTextWidth(300, 20, 200);
this.assertApprox(shrunk.fontSize, (20 * 200) / 300, 1e-9, "the font shrinks to fit");
this.assertNull(shrunk.textLength, "no spacing is forced when the font shrank");
// A short run is opened up, but never by more than MAX_KERNING times.
const widened = fitTextWidth(50, 20, 900);
this.assertApprox(widened.fontSize, 20, 1e-9, "the font is untouched");
this.assertApprox(widened.textLength, 50 * MAX_KERNING, 1e-9, "kerning is capped");
// A run that already fits and needs no help is left alone.
const natural = fitTextWidth(100, 20, 100);
this.assertNull(natural.textLength, "a fitting run keeps its natural spacing");
}
async test_the_label_keeps_a_margin_off_the_holding() {
const topo = new MapTopology({ x: 12, y: 10 }, false);
const entries = [];
for (let y = 0; y < 4; y++) {
for (let x = 0; x < 9; x++) entries.push([x, y, 0]);
}
const component = territoryComponents(territoryOf(entries), topo)[0];
const band = fitLabelBand(component.points);
this.assertNotNull(band, "the block gets a band");
const axis = Math.cos(band.angle);
const projected = component.points
.map((p) => p.x * axis + p.y * Math.sin(band.angle))
.sort((a, b) => a - b);
const span = projected[projected.length - 1] - projected[0];
this.assertLess(band.maxLength, span, "the text run stops short of the extremes");
this.assertGreater(band.fontSize, MIN_FONT, "the text is still readable");
}
async test_the_name_is_drawn_once_per_contiguous_holding() {
const topo = new MapTopology({ x: 10, y: 10 }, false);
const entries = [];
for (let y = 0; y < 3; y++) {
for (let x = 0; x < 4; x++) entries.push([x, y, 0]);
}
// A second, separate holding of the same nation.
for (let y = 6; y < 8; y++) {
for (let x = 6; x < 9; x++) entries.push([x, y, 0]);
}
// A holding of another nation, away from the first.
for (let y = 5; y < 8; y++) {
for (let x = 0; x < 4; x++) entries.push([x, y, 1]);
}
const labels = countryLabels(territoryOf(entries), CIVS, topo);
this.assertSize(labels, 3, "one label per contiguous holding");
const names = labels.map((label) => label.name).sort();
this.assertEqual(names, ["Britain", "France", "France"], "the owner names are used");
const france = labels.filter((label) => label.civ === 0);
this.assertEqual(france.length, 2, "the split holding is labelled twice");
}
async test_a_label_sits_inside_its_holding() {
const topo = new MapTopology({ x: 12, y: 10 }, false);
const entries = [];
for (let y = 1; y < 5; y++) {
for (let x = 2; x < 9; x++) entries.push([x, y, 0]);
}
const territory = territoryOf(entries);
const component = territoryComponents(territory, topo)[0];
const band = fitLabelBand(component.points);
this.assertNotNull(band, "the block gets a band");
const xs = component.points.map((p) => p.x);
const ys = component.points.map((p) => p.y);
this.assertTrue(band.x >= Math.min(...xs) - 63 && band.x <= Math.max(...xs) + 63,
"the label anchors over the holding");
this.assertTrue(band.y >= Math.min(...ys) - 56 && band.y <= Math.max(...ys) + 56,
"the label anchors over the holding");
// The band height is bounded by the room across the holding.
this.assertLess(band.fontSize, 4 * 56, "the text fits across the holding");
}
async test_tiny_holdings_get_no_name() {
const topo = new MapTopology({ x: 6, y: 6 }, false);
const labels = countryLabels(territoryOf([[0, 0, 0]]), CIVS, topo);
this.assertEmpty(labels, "a lone tile is not labelled");
}
async test_political_mode_matches_the_zoom_threshold() {
this.assertTrue(politicalMode(POLITICAL_ZOOM), "the threshold itself is political");
this.assertTrue(politicalMode(0.1), "far out is political");
this.assertFalse(politicalMode(POLITICAL_ZOOM + 0.01), "closer in is terrain");
}
// A zero-sized outer SVG is not painted at all in some browsers, however
// visible its overflow, so the viewport has to have a real size or the names
// are drawn but never seen.
async test_the_country_name_svg_has_a_non_zero_viewport() {
const state = smallState();
const env = await setupDom();
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);
try {
map.applySnapshot(state.snapshot(0));
const entries = [];
for (let y = 2; y < 5; y++) {
for (let x = 2; x < 7; x++) entries.push([x, y, 0]);
}
map._applyTerritory(entries);
map.setMapMode("political");
const svg = env.$("#layer-political-labels svg");
this.assertSize(svg, 1, "the country-name svg is built");
this.assertGreater(Number(svg.attr("width")), 0, "the viewport has a width");
this.assertGreater(Number(svg.attr("height")), 0, "the viewport has a height");
this.assertGreater(env.$("#layer-political-labels text").length, 0, "a name is drawn");
} finally {
teardownDom(env);
}
}
async test_the_dom_map_paints_country_colours_and_clears_them() {
const state = smallState();
const env = await setupDom();
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);
const snapshot = state.snapshot(0);
try {
map.applySnapshot(snapshot);
this.assertGreater(map.territory.size, 0, "the snapshot has territory");
map.camera.zoom = 0.2;
map._applyCamera();
this.assertTrue(map.political, "the far zoom switches to the political map");
this.assertTrue(map.$world.hasClass("political"), "the world is flagged political");
const owned = snapshottedColourTile(env);
this.assertNotNull(owned, "an owned tile is painted a flat country colour");
const style = owned.getAttribute("style") || "";
this.assertFalse(/terrain\.png|textures\//.test(style), "the terrain texture is gone");
this.assertTrue(/background-color/.test(style), "the tile keeps a country colour");
map.camera.zoom = 1.0;
map._applyCamera();
this.assertFalse(map.political, "zooming back restores the terrain map");
this.assertFalse(map.$world.hasClass("political"), "the political flag is cleared");
this.assertSize(env.$("#layer-political-labels text"), 0, "the names are removed");
} finally {
teardownDom(env);
}
}
}
// The first on-screen terrain hex whose colour is one of the catalogue's, i.e.
// a tile a nation owns. jsdom turns the colour into `rgb(...)`, so compare the
// numeric channels of every civ rather than the original hex string.
function snapshottedColourTile(env) {
const colours = new Set();
for (const civ of CIVILISATIONS) {
colours.add(civ.primaryColour.toLowerCase());
colours.add(cssColour(civ.primaryColour));
}
let found = null;
env.$("#layer-terrain .hex.terrain").each((_index, element) => {
if (!found && colours.has(element.style.backgroundColor)) found = element;
});
return found;
}
function cssColour(hex) {
const value = hex.replace("#", "");
return `rgb(${parseInt(value.slice(0, 2), 16)}, ${parseInt(value.slice(2, 4), 16)}, ${parseInt(value.slice(4, 6), 16)})`;
}