mapwright 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mapwright/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """mapwright — domain-neutral procedural fantasy map & world generation.
2
+
3
+ A dependency-light (numpy-only) library:
4
+
5
+ * :class:`SeededRNG` — one seed drives everything; ``.derive(label)`` gives
6
+ independent, reproducible sub-streams.
7
+ * :class:`NameGenerator` — order-k Markov place/person names in several culture
8
+ styles, seed-reproducible across processes.
9
+ * :class:`RegionalTerrainGenerator` — Voronoi cells (Lloyd-relaxed) → heightmap
10
+ → Planchon–Darboux depression fill → flux + hydraulic/creep erosion → rivers
11
+ → latitude/elevation climate → Whittaker biomes. Returns neutral data
12
+ (:class:`Biome`, :class:`TerrainResult`); mapping it onto a host app's tile
13
+ vocabulary is the caller's job.
14
+ * :class:`RegionalSVGRenderer` — shaded-relief (hillshade) SVG: biome polygons,
15
+ coastline, rivers, labelled :class:`Marker` points.
16
+
17
+ Built clean-room from the published ideas in Azgaar's Fantasy-Map-Generator (MIT)
18
+ and rlguy/Mewo2's FantasyMapGenerator (Zlib). See NOTICE.
19
+
20
+ Quickstart::
21
+
22
+ from mapwright import SeededRNG, RegionalTerrainGenerator, RegionalSVGRenderer
23
+ terrain = RegionalTerrainGenerator(SeededRNG(7)).generate(60, 40)
24
+ svg = RegionalSVGRenderer().render(terrain)
25
+ """
26
+
27
+ from .config import WorldMapConfig, PRESETS
28
+ from .dungeon import Dungeon, DungeonConfig, DungeonGenerator, Rect
29
+ from .names import NameGenerator, MarkovNameGenerator, NAMEBASES
30
+ from .rng import SeededRNG
31
+ from .svg_renderer import Marker, RegionalSVGRenderer
32
+ from .terrain import (
33
+ Biome,
34
+ River,
35
+ TerrainCell,
36
+ TerrainResult,
37
+ RegionalTerrainGenerator,
38
+ compute_cell_polygons,
39
+ )
40
+
41
+ __version__ = "0.2.0"
42
+
43
+ __all__ = [
44
+ "SeededRNG",
45
+ "WorldMapConfig",
46
+ "PRESETS",
47
+ "NameGenerator",
48
+ "MarkovNameGenerator",
49
+ "NAMEBASES",
50
+ "Biome",
51
+ "River",
52
+ "TerrainCell",
53
+ "TerrainResult",
54
+ "RegionalTerrainGenerator",
55
+ "compute_cell_polygons",
56
+ "Marker",
57
+ "RegionalSVGRenderer",
58
+ "Dungeon",
59
+ "DungeonConfig",
60
+ "DungeonGenerator",
61
+ "Rect",
62
+ ]
mapwright/config.py ADDED
@@ -0,0 +1,154 @@
1
+ """Tunable world-generation parameters.
2
+
3
+ ``WorldMapConfig`` is the single knob-set that shapes a generated world. Every
4
+ field is a bounded scalar or small int with a clear semantic and a default that
5
+ yields a balanced single-continent world — so it doubles as a **schema a host
6
+ app (or an LLM) can populate from a description**: "a frozen archipelago of
7
+ scattered isles" → ``WorldMapConfig(continents=7, sea_level=0.55, temperature=-0.8)``.
8
+
9
+ The library deliberately depends only on numpy, so this is a plain dataclass (no
10
+ pydantic). A host that wants JSON-schema/LLM population can mirror these fields
11
+ in its own model and call :meth:`WorldMapConfig.from_dict` (which clamps to
12
+ valid ranges, so a sloppy LLM payload can't produce a broken world).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import asdict, dataclass, fields
18
+
19
+
20
+ def _clamp(value: float, lo: float, hi: float) -> float:
21
+ return max(lo, min(hi, value))
22
+
23
+
24
+ # Single source of truth for every knob: (name, type, min, max, description).
25
+ # Drives both __post_init__ clamping and json_schema(), so the validation
26
+ # behaviour and the published contract can never disagree.
27
+ _SPEC: list[tuple] = [
28
+ ("sea_level", float, 0.05, 0.9,
29
+ "Fraction of the height range below water. Higher = more ocean."),
30
+ ("continents", int, 1, 24,
31
+ "Number of major landmasses. 1 = single continent; 4-8 = archipelago."),
32
+ ("continent_spread", float, 0.0, 1.0,
33
+ "0 = landmasses cluster at the centre; 1 = pushed toward the edges."),
34
+ ("edge_falloff", float, 0.0, 2.0,
35
+ "0 = land may reach the map border; 1 = strong 'ringed by sea' coastline."),
36
+ ("mountain_density", float, 0.0, 1.0,
37
+ "Abundance and height of hills/ranges."),
38
+ ("roughness", float, 0.0, 1.0,
39
+ "Terrain detail (number of erosion passes)."),
40
+ ("temperature", float, -1.0, 1.0,
41
+ "Global temperature bias: -1 frozen .. +1 scorching."),
42
+ ("moisture", float, -1.0, 1.0,
43
+ "Global moisture bias: -1 arid .. +1 drowned."),
44
+ ("river_density", float, 0.0, 1.0,
45
+ "How readily rivers are traced; more = more, smaller rivers."),
46
+ ]
47
+
48
+
49
+ @dataclass
50
+ class WorldMapConfig:
51
+ """Knobs for :meth:`RegionalTerrainGenerator.generate`. Defaults = baseline."""
52
+
53
+ # --- Topology / landmass ---
54
+ sea_level: float = 0.32
55
+ """0..1 — fraction of the height range below water. Higher ⇒ more ocean."""
56
+ continents: int = 1
57
+ """Number of major landmasses. 1 = single continent; 4–8 ⇒ archipelago."""
58
+ continent_spread: float = 0.5
59
+ """0 ⇒ landmasses cluster at the centre; 1 ⇒ pushed toward the edges."""
60
+ edge_falloff: float = 1.0
61
+ """0 ⇒ land may reach the map border; 1 ⇒ strong 'ringed by sea' coastline."""
62
+
63
+ # --- Relief ---
64
+ mountain_density: float = 0.5
65
+ """0..1 — abundance and height of hills/ranges."""
66
+ roughness: float = 0.5
67
+ """0..1 — terrain detail (drives the number of erosion passes)."""
68
+
69
+ # --- Climate ---
70
+ temperature: float = 0.0
71
+ """-1 (frozen) .. +1 (scorching) — global temperature bias."""
72
+ moisture: float = 0.0
73
+ """-1 (arid) .. +1 (drowned) — global moisture bias."""
74
+
75
+ # --- Hydrology ---
76
+ river_density: float = 0.5
77
+ """0..1 — how readily rivers are traced (more ⇒ more, smaller rivers)."""
78
+
79
+ def __post_init__(self) -> None:
80
+ # Clamp everything so out-of-range inputs (e.g. from an LLM) are safe.
81
+ for name, typ, lo, hi, _desc in _SPEC:
82
+ value = _clamp(getattr(self, name), lo, hi)
83
+ setattr(self, name, int(value) if typ is int else float(value))
84
+
85
+ # -- serialisation / interop ----------------------------------------
86
+
87
+ def to_dict(self) -> dict:
88
+ return asdict(self)
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict) -> "WorldMapConfig":
92
+ """Build from a (possibly partial / noisy) mapping; unknown keys ignored."""
93
+ known = {f.name for f in fields(cls)}
94
+ return cls(**{k: v for k, v in data.items() if k in known})
95
+
96
+ @classmethod
97
+ def json_schema(cls) -> dict:
98
+ """A JSON Schema (draft 2020-12) describing this config.
99
+
100
+ This is mapwright's machine-readable **contract** for world parameters:
101
+ a host app or an LLM can validate/generate payloads against it, then feed
102
+ them through :meth:`from_dict` (which additionally clamps). Generated from
103
+ the same field spec used for clamping, so schema and behaviour can't drift.
104
+ """
105
+ defaults = cls()
106
+ properties = {
107
+ name: {
108
+ "type": "integer" if typ is int else "number",
109
+ "minimum": lo,
110
+ "maximum": hi,
111
+ "default": getattr(defaults, name),
112
+ "description": desc,
113
+ }
114
+ for name, typ, lo, hi, desc in _SPEC
115
+ }
116
+ return {
117
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
118
+ "title": "WorldMapConfig",
119
+ "description": "Parameters that shape a mapwright world.",
120
+ "type": "object",
121
+ "additionalProperties": False,
122
+ "properties": properties,
123
+ }
124
+
125
+ # -- presets (also demonstrate the knobs & seed LLM choices) --------
126
+
127
+ @classmethod
128
+ def preset(cls, name: str) -> "WorldMapConfig":
129
+ """A named starting point. Raises KeyError for an unknown preset."""
130
+ return cls.from_dict(dict(PRESETS[name]))
131
+
132
+ @staticmethod
133
+ def preset_names() -> list[str]:
134
+ return sorted(PRESETS.keys())
135
+
136
+
137
+ # Named presets — ready-made worlds and good LLM anchors. Each maps to a kind of
138
+ # narrative setting; a host can expose these by name or let an LLM pick + tweak.
139
+ PRESETS: dict[str, dict] = {
140
+ "continent": {}, # the balanced default
141
+ "pangaea": {"continents": 1, "sea_level": 0.22, "continent_spread": 0.15,
142
+ "edge_falloff": 0.55},
143
+ "archipelago": {"continents": 7, "sea_level": 0.55, "continent_spread": 0.75,
144
+ "mountain_density": 0.4},
145
+ "highlands": {"continents": 1, "mountain_density": 0.95, "roughness": 0.75,
146
+ "river_density": 0.7},
147
+ "desert": {"temperature": 0.85, "moisture": -0.85, "sea_level": 0.28,
148
+ "mountain_density": 0.25, "river_density": 0.12},
149
+ "arctic": {"temperature": -0.85, "moisture": 0.1, "mountain_density": 0.5},
150
+ "tropical": {"temperature": 0.6, "moisture": 0.85, "river_density": 0.85,
151
+ "mountain_density": 0.55},
152
+ "islands": {"continents": 12, "sea_level": 0.62, "continent_spread": 0.85,
153
+ "mountain_density": 0.3},
154
+ }
mapwright/dungeon.py ADDED
@@ -0,0 +1,229 @@
1
+ """Procedural dungeon generation: BSP rooms + minimum-spanning-tree corridors.
2
+
3
+ Clean-room from the ideas of two permissively-licensed generators:
4
+ * **BSP space partitioning** — Adrian Kulawik's *Dungeon-Generator* (MIT): split
5
+ the map recursively into leaves and place one room per leaf, so rooms never
6
+ overlap and space is used evenly.
7
+ * **MST connectivity** — *donjuan* (CC0): connect rooms with a minimum spanning
8
+ tree (Prim's) so every room is reachable with no redundant corridors, then add
9
+ a few extra edges for loops.
10
+
11
+ Domain-neutral: returns rooms (rectangles), carved corridor cells, and a boolean
12
+ walkable grid. A host maps these onto its own tiles/doors/encounters. Fully
13
+ seed-deterministic via :class:`SeededRNG`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ import numpy as np
21
+
22
+ from .rng import SeededRNG
23
+
24
+
25
+ @dataclass
26
+ class Rect:
27
+ """An axis-aligned room rectangle (integer tile coordinates)."""
28
+
29
+ x: int
30
+ y: int
31
+ w: int
32
+ h: int
33
+
34
+ @property
35
+ def cx(self) -> int:
36
+ return self.x + self.w // 2
37
+
38
+ @property
39
+ def cy(self) -> int:
40
+ return self.y + self.h // 2
41
+
42
+ @property
43
+ def center(self) -> tuple[int, int]:
44
+ return (self.cx, self.cy)
45
+
46
+ def intersects(self, other: "Rect", pad: int = 0) -> bool:
47
+ return (
48
+ self.x - pad < other.x + other.w
49
+ and self.x + self.w + pad > other.x
50
+ and self.y - pad < other.y + other.h
51
+ and self.y + self.h + pad > other.y
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class DungeonConfig:
57
+ """Knobs for dungeon generation. Defaults give a balanced multi-room layout."""
58
+
59
+ min_leaf: int = 8
60
+ """Smallest BSP leaf (tiles) — a leaf this size or smaller is not split."""
61
+ room_min: int = 3
62
+ """Smallest room edge length."""
63
+ room_padding: int = 1
64
+ """Gap kept between a room and its leaf's edges."""
65
+ split_jitter: float = 0.35
66
+ """0 = always split a leaf in half; up to 0.5 = split position varies."""
67
+ extra_corridor_chance: float = 0.12
68
+ """Probability per non-tree room-pair of an extra (loop) corridor."""
69
+
70
+
71
+ @dataclass
72
+ class Dungeon:
73
+ """Generated dungeon: rooms, carved corridor cells, and a walkable grid."""
74
+
75
+ width: int
76
+ height: int
77
+ rooms: list[Rect]
78
+ corridors: list[tuple[int, int]]
79
+ grid: np.ndarray # bool [height, width]; True = floor
80
+ edges: list[tuple[int, int]] = field(default_factory=list) # connected room-index pairs
81
+
82
+ def ascii(self) -> str:
83
+ """A quick ``#``/``.`` map (rooms+corridors as floor) for eyeballing/tests."""
84
+ rows = []
85
+ for y in range(self.height):
86
+ rows.append("".join("." if self.grid[y, x] else "#" for x in range(self.width)))
87
+ return "\n".join(rows)
88
+
89
+
90
+ class DungeonGenerator:
91
+ """Builds a :class:`Dungeon` for a ``width×height`` grid."""
92
+
93
+ def __init__(self, rng: SeededRNG):
94
+ self._rng = rng.derive("dungeon")
95
+
96
+ def generate(
97
+ self, width: int, height: int, config: DungeonConfig | None = None
98
+ ) -> Dungeon:
99
+ cfg = config or DungeonConfig()
100
+ leaves = self._bsp(0, 0, width, height, cfg)
101
+ rooms = [r for leaf in leaves if (r := self._room_in_leaf(leaf, cfg))]
102
+
103
+ grid = np.zeros((height, width), dtype=bool)
104
+ for r in rooms:
105
+ grid[r.y : r.y + r.h, r.x : r.x + r.w] = True
106
+
107
+ corridors, edges = self._connect(rooms, grid, cfg)
108
+ return Dungeon(width, height, rooms, corridors, grid, edges)
109
+
110
+ # -- 1. BSP partitioning --------------------------------------------
111
+
112
+ def _bsp(self, x: int, y: int, w: int, h: int, cfg: DungeonConfig) -> list[Rect]:
113
+ """Recursively split a region into leaves no smaller than ``min_leaf``."""
114
+ # Decide whether (and how) to split. Stop when too small to yield two
115
+ # viable children in either axis.
116
+ can_h = w >= 2 * cfg.min_leaf
117
+ can_v = h >= 2 * cfg.min_leaf
118
+ if not can_h and not can_v:
119
+ return [Rect(x, y, w, h)]
120
+
121
+ # Prefer splitting the longer axis so leaves stay squarish.
122
+ if can_h and can_v:
123
+ split_horizontal = w > h if abs(w - h) > cfg.min_leaf else self._rng.chance(0.5)
124
+ else:
125
+ split_horizontal = can_h
126
+
127
+ if split_horizontal:
128
+ lo, hi = cfg.min_leaf, w - cfg.min_leaf
129
+ cut = self._jittered_cut(lo, hi, w, cfg)
130
+ return (self._bsp(x, y, cut, h, cfg)
131
+ + self._bsp(x + cut, y, w - cut, h, cfg))
132
+ else:
133
+ lo, hi = cfg.min_leaf, h - cfg.min_leaf
134
+ cut = self._jittered_cut(lo, hi, h, cfg)
135
+ return (self._bsp(x, y, w, cut, cfg)
136
+ + self._bsp(x, y + cut, w, h - cut, cfg))
137
+
138
+ def _jittered_cut(self, lo: int, hi: int, span: int, cfg: DungeonConfig) -> int:
139
+ mid = span / 2
140
+ jitter = span * cfg.split_jitter
141
+ cut = int(round(self._rng.uniform(mid - jitter, mid + jitter)))
142
+ return max(lo, min(hi, cut))
143
+
144
+ # -- 2. Rooms --------------------------------------------------------
145
+
146
+ def _room_in_leaf(self, leaf: Rect, cfg: DungeonConfig) -> Rect | None:
147
+ """Place a random room inside a leaf, inset by ``room_padding``."""
148
+ pad = cfg.room_padding
149
+ max_w = leaf.w - 2 * pad
150
+ max_h = leaf.h - 2 * pad
151
+ if max_w < cfg.room_min or max_h < cfg.room_min:
152
+ return None
153
+ rw = self._rng.randint(cfg.room_min, max_w)
154
+ rh = self._rng.randint(cfg.room_min, max_h)
155
+ rx = leaf.x + pad + self._rng.randint(0, max_w - rw)
156
+ ry = leaf.y + pad + self._rng.randint(0, max_h - rh)
157
+ return Rect(rx, ry, rw, rh)
158
+
159
+ # -- 3. Corridors (MST + loops) -------------------------------------
160
+
161
+ def _connect(
162
+ self, rooms: list[Rect], grid: np.ndarray, cfg: DungeonConfig
163
+ ) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]:
164
+ """Connect rooms with a Prim MST (+ optional loops); carve L-corridors."""
165
+ n = len(rooms)
166
+ corridors: list[tuple[int, int]] = []
167
+ edges: list[tuple[int, int]] = []
168
+ if n < 2:
169
+ return corridors, edges
170
+
171
+ centers = [r.center for r in rooms]
172
+
173
+ def dist2(i: int, j: int) -> int:
174
+ (ax, ay), (bx, by) = centers[i], centers[j]
175
+ return (ax - bx) ** 2 + (ay - by) ** 2
176
+
177
+ # Prim's MST over room centers (dense graph, n is small).
178
+ in_tree = {0}
179
+ while len(in_tree) < n:
180
+ best = None
181
+ best_d = None
182
+ for i in in_tree:
183
+ for j in range(n):
184
+ if j in in_tree:
185
+ continue
186
+ d = dist2(i, j)
187
+ if best_d is None or d < best_d:
188
+ best_d, best = d, (i, j)
189
+ i, j = best
190
+ in_tree.add(j)
191
+ edges.append((i, j))
192
+
193
+ # A few extra edges → loops (less tree-like, more interesting).
194
+ for i in range(n):
195
+ for j in range(i + 1, n):
196
+ if (i, j) in edges or (j, i) in edges:
197
+ continue
198
+ if self._rng.chance(cfg.extra_corridor_chance):
199
+ edges.append((i, j))
200
+
201
+ for i, j in edges:
202
+ corridors.extend(self._carve_l(centers[i], centers[j], grid))
203
+ return corridors, edges
204
+
205
+ def _carve_l(
206
+ self, a: tuple[int, int], b: tuple[int, int], grid: np.ndarray
207
+ ) -> list[tuple[int, int]]:
208
+ """Carve an L-shaped corridor between two points; return the new cells."""
209
+ (ax, ay), (bx, by) = a, b
210
+ cells: list[tuple[int, int]] = []
211
+ h_first = self._rng.chance(0.5)
212
+ corner = (bx, ay) if h_first else (ax, by)
213
+
214
+ def line(p: tuple[int, int], q: tuple[int, int]) -> None:
215
+ (px, py), (qx, qy) = p, q
216
+ if px == qx:
217
+ for y in range(min(py, qy), max(py, qy) + 1):
218
+ if not grid[y, px]:
219
+ grid[y, px] = True
220
+ cells.append((px, y))
221
+ else:
222
+ for x in range(min(px, qx), max(px, qx) + 1):
223
+ if not grid[py, x]:
224
+ grid[py, x] = True
225
+ cells.append((x, py))
226
+
227
+ line(a, corner)
228
+ line(corner, b)
229
+ return cells