Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion backend/src/api/data_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
import json, logging, os, time
import math

from .http_headers import (
add_no_cache,
Expand Down Expand Up @@ -93,6 +94,15 @@ def compute_trade_shares(trade: dict):
best, dominant = v, g
return shares, dominant, best / total


def _finite_number(value):
if isinstance(value, bool) or not isinstance(value, (int, float)):
return False
try:
return math.isfinite(value)
except OverflowError:
return False

def build_compiled_provinces(map_name: str):
meta = load_province_metadata(map_name)

Expand All @@ -119,7 +129,7 @@ def build_compiled_provinces(map_name: str):
except (TypeError, ValueError):
inf = None

out[pid] = {
province = {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
**m,
"province_id": pid,
"prosperity": p.get("prosperity", 0),
Expand All @@ -132,6 +142,11 @@ def build_compiled_provinces(map_name: str):
"infestation_group": inf.get("group") if inf else None,
"infestation_display": (inf.get("display") or inf.get("group")) if inf else None,
}
for field in ("terrain_value", "infrastructure", "infrastructure_fill", "effective_terrain"):
value = p.get(field)
if _finite_number(value):
province[field] = value
out[pid] = province

return out

Expand Down
43 changes: 43 additions & 0 deletions backend/src/api/test_infrastructure_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import json
import unittest
from unittest.mock import mock_open, patch

from src.api import data_routes


class CompiledInfrastructureTests(unittest.TestCase):
def test_copies_only_finite_infrastructure_values(self) -> None:
province_data = [
{
"id": 1,
"terrain_value": 0.4,
"infrastructure": 12,
"infrastructure_fill": 0.6,
"effective_terrain": 0.61,
},
{
"id": 2,
"terrain_value": "0.4",
"infrastructure": float("nan"),
"infrastructure_fill": None,
"effective_terrain": True,
},
]
with (
patch.object(data_routes, "load_province_metadata", return_value={1: {"terrain": "bog"}, 2: {"terrain": "plains"}}),
patch.object(data_routes, "load_infestation_by_id", return_value={}),
patch.object(data_routes, "input_file", return_value="province_data.json"),
patch("builtins.open", mock_open(read_data=json.dumps(province_data))),
):
result = data_routes.build_compiled_provinces("main")

self.assertEqual(
{key: result[1][key] for key in ("terrain_value", "infrastructure", "infrastructure_fill", "effective_terrain")},
{"terrain_value": 0.4, "infrastructure": 12, "infrastructure_fill": 0.6, "effective_terrain": 0.61},
)
for field in ("terrain_value", "infrastructure", "infrastructure_fill", "effective_terrain"):
self.assertNotIn(field, result[2])


if __name__ == "__main__":
unittest.main()
106 changes: 106 additions & 0 deletions backend/src/scripts/mapgen/infrastructuregen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import json
import math
import os

from ..loader.provinces import load_provinces
from ..loader.province_metadata import load_province_metadata
from ..util.dirs import input_file, validate_map
from .geometry_cache import MapGeometryCache
from .map_paint_numpy import (
load_provinces_array,
paint_from_rgb_lut,
rgba_array_to_image,
)


INFRASTRUCTURE_TERRAIN_MIN = 0.30
INFRASTRUCTURE_TERRAIN_MAX = 0.75
INFRASTRUCTURE_COLOR_STOPS = (
(244, 160, 160),
(140, 70, 170),
(20, 40, 120),
)
INFRASTRUCTURE_ALPHA = 200
SKIP_TERRAINS = {"water", "sea"}


def infrastructure_to_color(value: float) -> tuple[int, int, int] | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
try:
if not math.isfinite(value):
return None
except OverflowError:
return None
t = max(
0.0,
min(
1.0,
(value - INFRASTRUCTURE_TERRAIN_MIN)
/ (INFRASTRUCTURE_TERRAIN_MAX - INFRASTRUCTURE_TERRAIN_MIN),
),
)
stops = INFRASTRUCTURE_COLOR_STOPS
if t <= 0.5:
first, second, fraction = stops[0], stops[1], t * 2
else:
first, second, fraction = stops[1], stops[2], (t - 0.5) * 2
return tuple(
math.floor(a + (b - a) * fraction + 0.5)
for a, b in zip(first, second)
)


def create_infrastructure_map(
map_name: str,
filename: str = "infrastructure",
cache: MapGeometryCache | None = None,
):
validate_map(map_name)

province_rgb_to_id = load_provinces(map_name)
province_meta = load_province_metadata(map_name)
try:
with open(input_file(map_name, "province_data.json"), "r") as f:
province_data = json.load(f)
except (OSError, json.JSONDecodeError):
province_data = []
if not isinstance(province_data, list):
province_data = []

province_by_id = {
p.get("id"): p
for p in province_data
if isinstance(p, dict) and isinstance(p.get("id"), (int, str))
}
rgb_to_rgba = {}

for rgb, pid in province_rgb_to_id.items():
meta = province_meta.get(pid)
if not isinstance(meta, dict) or meta.get("terrain") in SKIP_TERRAINS:
continue
row = province_by_id.get(pid)
if row is None:
row = province_by_id.get(str(pid))
if not isinstance(row, dict):
continue
color = infrastructure_to_color(row.get("effective_terrain"))
if color is not None:
rgb_to_rgba[rgb] = (*color, INFRASTRUCTURE_ALPHA)

provinces = (
cache.provinces_rgba
if cache is not None
else load_provinces_array(input_file(map_name, "provinces.png"))
)
painted = paint_from_rgb_lut(provinces, rgb_to_rgba, skip_black=False)
painted_pixels = int((painted[:, :, 3] > 0).sum())
output_path = os.path.abspath(
os.path.join(
os.path.dirname(input_file(map_name, "dummy")),
"..", "..", "output", map_name, "maps", f"{filename}.png"
)
)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
rgba_array_to_image(painted).save(output_path, "PNG")
print(f"🛠 Infrastructure map generated → {output_path} | painted={painted_pixels:,}")
21 changes: 21 additions & 0 deletions backend/src/scripts/mapgen/mapmodes/infrastructure_mapmode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import argparse
import sys

from ...mapgen.infrastructuregen import create_infrastructure_map
from ...util.dirs import validate_map


def main() -> None:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")

parser = argparse.ArgumentParser(description="Generate infrastructure overlay map PNG")
parser.add_argument("--map", default="dev", help="Map id (e.g. main, dev)")
args = parser.parse_args()

validate_map(args.map)
create_infrastructure_map(args.map, filename="infrastructure_map")


if __name__ == "__main__":
main()
24 changes: 24 additions & 0 deletions backend/src/scripts/mapgen/test_infrastructuregen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import unittest

from .infrastructuregen import (
INFRASTRUCTURE_COLOR_STOPS,
infrastructure_to_color,
)


class InfrastructureColorTests(unittest.TestCase):
def test_color_stops_and_clamping(self) -> None:
self.assertEqual(infrastructure_to_color(0.30), INFRASTRUCTURE_COLOR_STOPS[0])
self.assertEqual(infrastructure_to_color(0.525), INFRASTRUCTURE_COLOR_STOPS[1])
self.assertEqual(infrastructure_to_color(0.75), INFRASTRUCTURE_COLOR_STOPS[2])
self.assertEqual(infrastructure_to_color(0.1), INFRASTRUCTURE_COLOR_STOPS[0])
self.assertEqual(infrastructure_to_color(0.9), INFRASTRUCTURE_COLOR_STOPS[2])

def test_bad_values_have_no_color(self) -> None:
for value in (None, "0.4", float("nan"), float("inf")):
with self.subTest(value=value):
self.assertIsNone(infrastructure_to_color(value))


if __name__ == "__main__":
unittest.main()
3 changes: 3 additions & 0 deletions backend/src/scripts/util/regeneration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from ..mapgen.infestationgen import create_infestation_map
from ..mapgen.prosperitygen import create_prosperity_map
from ..mapgen.infrastructuregen import create_infrastructure_map
from ..mapgen.regiongen import generate_regions
from ..mapgen.zocgen import generate_zoc_overlays
from ..map_tools.province_geometry import write_province_geometry
Expand Down Expand Up @@ -174,6 +175,8 @@ def _run_mode_serial(
if mode == "trade":
with timings.timed(f"{mode}.prosperity"):
create_prosperity_map(map_name, "prosperity_map", cache=cache)
with timings.timed(f"{mode}.infrastructure"):
create_infrastructure_map(map_name, "infrastructure_map", cache=cache)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

with timings.timed(f"{mode}.map"):
create_map(map_name, mode, f"{mode}_map", False, cache=cache)
Expand Down
14 changes: 14 additions & 0 deletions backend/src/scripts/util/test_regeneration_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ def _queue(map_name: str, mode: str) -> list:
runnable = modes_to_run("main", parse_regen_type("queued"))
self.assertEqual(runnable, ["nation", "trade"])

def test_trade_regen_draws_infrastructure_map(self) -> None:
from . import regeneration

with (
patch.object(regeneration, "create_map"),
patch.object(regeneration, "create_prosperity_map"),
patch.object(regeneration, "create_infrastructure_map") as infrastructure,
patch.object(regeneration, "generate_regions"),
):
regeneration._run_mode_serial(
"main", "trade", parse_regen_type("fullregen"), None, _RegenTimings()
)
infrastructure.assert_called_once_with("main", "infrastructure_map", cache=None)


if __name__ == "__main__":
unittest.main()
6 changes: 3 additions & 3 deletions frontend/app/components/MapViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => {
/*
* A stored day offers the *same* mode list as the live map, with no
* filtering. Every mode now has an honest day answer: the day-varying ones
* (`nation`, `trade`, `empire`, `prosperity`, `infestation`) come out of that
* (`nation`, `trade`, `empire`, `prosperity`, `infrastructure`, `infestation`) come out of that
* day's capture, and the static ones (`terrain`, `fertility`, `province`)
* are province geometry that does not change day to day, so their live
* source *is* their historical answer. Title modes (`county`, `duchy`,
Expand Down Expand Up @@ -244,7 +244,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => {
day,
});
/**
* `prosperity` and `infestation` under a stored day. Both are drawn on the
* `prosperity`, `infrastructure` and `infestation` under a stored day. These are drawn on the
* live map as `/{mapId}/mapdata/{mode}`, a raster regenerated from today's
* data with no per-day variant, so the day page paints them itself from that
* day's captured file — see `ChronicleProvincePaintLayer`.
Expand Down Expand Up @@ -785,7 +785,7 @@ const MapViewer = ({ mapId, day = null }: MapViewerProps) => {
*/
/**
* Either the region source for this mode is missing from the day, or (for
* `prosperity`/`infestation`) the raster source is. `main` has no
* `prosperity`/`infrastructure`/`infestation`) the raster source is. `main` has no
* `infestation_data.json` at all, so that mode lands here on every day —
* which is the honest answer, not an error.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {

/**
* The chronicle's replacement for `/{map}/mapdata/{mode}` — the server-rendered
* raster the live map shows for `prosperity` and `infestation`.
* raster the live map shows for `prosperity`, `infrastructure` and `infestation`.
*
* Those PNGs are regenerated from *today's* data and have no per-day variant,
* so a stored day paints its own from that day's captured file. Same quarter-
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/components/map/MapCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ type MapCanvasProps = {
/**
* A chronicle day, or `null` for the live map. Read only by
* `showsLiveProvinceRaster` below, which is what stops a stored day from
* showing today's prosperity or infestation raster.
* showing today's prosperity, infrastructure or infestation raster.
*/
day?: string | null;
/**
Expand Down Expand Up @@ -270,7 +270,7 @@ export default function MapCanvas({
}, [mapId, mapType, viewport.resetViewport]);

/**
* The four modes drawn as a full-map raster over the base map rather than as
* The province modes drawn as a full-map raster over the base map rather than as
* region shapes. `showsLiveProvinceRaster` then decides *which* raster: the
* server's, or — for `prosperity` and `infestation` under a stored day —
* `provinceOverlay`, painted from that day's capture.
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/components/map/MapToolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ const ALL_MODES: MapMode[] = [
"fertility",
"trade",
"prosperity",
"infrastructure",
"infestation",
];

describe("mapToolbarModeOptions", () => {
it("offers every map mode, including extras that used to be main/dev only", () => {
const values = mapToolbarModeOptions().map((opt) => opt.value);
expect(values).toHaveLength(11);
expect(values).toHaveLength(12);
expect(values).toEqual(ALL_MODES);
});
});
1 change: 1 addition & 0 deletions frontend/app/components/map/MapToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const EXTRA_MODE_OPTIONS: { value: MapMode; label: string }[] = [
{ value: "fertility", label: "Fertility" },
{ value: "trade", label: "Trade" },
{ value: "prosperity", label: "Prosperity" },
{ value: "infrastructure", label: "Infrastructure" },
{ value: "infestation", label: "Infestation" },
];

Expand Down
1 change: 1 addition & 0 deletions frontend/app/components/map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type MapMode =
| "empire"
| "trade"
| "prosperity"
| "infrastructure"
| "terrain"
| "fertility"
| "infestation"
Expand Down
1 change: 1 addition & 0 deletions frontend/app/hooks/regionPick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function provinceHoverBlocksRegionPick(mapType: string): boolean {
mapType === "terrain" ||
mapType === "fertility" ||
mapType === "prosperity" ||
mapType === "infrastructure" ||
mapType === "infestation" ||
mapType === "province"
);
Expand Down
1 change: 1 addition & 0 deletions frontend/app/hooks/useMapModeData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function useMapModeData({
mapType === "terrain" ||
mapType === "fertility" ||
mapType === "prosperity" ||
mapType === "infrastructure" ||
mapType === "infestation" ||
mapType === "province"
) {
Expand Down
Loading