diff --git a/examples/door-game/tests/test_world_loader.py b/examples/door-game/tests/test_world_loader.py index 3eb7fdd8..02221394 100644 --- a/examples/door-game/tests/test_world_loader.py +++ b/examples/door-game/tests/test_world_loader.py @@ -828,3 +828,24 @@ def test_single_boss_accepted() -> None: bosses = [m for m in world.monsters if m.boss] assert len(bosses) == 1 assert bosses[0].name == "the Wyrm Below" + + +def test_overlapping_zones_rejected(tmp_path: Path) -> None: + """Overlapping zone rectangles are a load error. + + ``zone_for`` returns the FIRST matching zone, so two zones sharing any cell + would silently shadow one tier band there — exactly the bug a cold-authored + pack shipped (a 1-column caldera-edge strip dropped to the low band). Pull + the deep zone west so its rect overlaps the near zone and confirm the loader + refuses it rather than loading the ambiguity. + """ + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + for zone in data["zones"]: + if zone["key"] == "dungeon_deep": + zone["rect"][0] = 50 # now overlaps forest_near's x30..60 strip + + _rewrite(pack / "world.json", mutate) + with pytest.raises(WorldLoadError, match="overlap"): + load_world(pack) diff --git a/examples/door-game/understone/cli.py b/examples/door-game/understone/cli.py index abbdd526..b11ffceb 100644 --- a/examples/door-game/understone/cli.py +++ b/examples/door-game/understone/cli.py @@ -385,29 +385,15 @@ def _render_validate_coverage() -> str: settings_count = len(loader.SETTINGS_BANDS) reserved = ", ".join(f"`{g}`" for g in _reserved_glyph_list()) bullets = [ - f"* **Economy and progression bands** — every one of the {settings_count} " - "`settings` fields must sit in its allowed range (the table above), and " - "`growth` must be present and non-negative.", - "* **Glyph safety** — every terrain, location, and legend glyph must render " - f"exactly one column and must not be a reserved marker ({reserved}).", - "* **Map integrity** — `width`/`height` in band, every `terrain_rows` row " - "exactly `width` long with `height` rows, and every row character in the " - "`legend`.", - "* **Walkability** — `spawn` and every placed location must sit on walkable " - "terrain (and no two locations share a cell).", - f"* **Display-name length** — every monster, item, and location name within " - f"`{loader.MAX_NAME_LEN}` printable characters; content lists within their caps.", - "* **The fight row** — `events.json` must hold at least one `fight` entry, " - "with weights `> 0`, `min <= max`, and amounts in their per-kind band.", - "* **Cross-references** — `legend` → terrain key, location placements → " - "`locations.json` keys, `starting_weapon`/`starting_armor` → item ids, " - '`boss_monster` → a monster flagged `"boss": true`, ' - "`rare_drop_item` → a consumable item id, and `forge_ore_item` → a " - "`material` item id.", + f"* **Economy and progression bands** — every one of the {settings_count} `settings` fields must sit in its allowed range (the table above), and `growth` must be present and non-negative.", + f"* **Glyph safety** — every terrain, location, and legend glyph must render exactly one column and must not be a reserved marker ({reserved}).", + "* **Map integrity** — `width`/`height` in band, every `terrain_rows` row exactly `width` long with `height` rows, and every row character in the `legend`.", + "* **Walkability** — `spawn` and every placed location must sit on walkable terrain (and no two locations share a cell).", + f"* **Display-name length** — every monster, item, and location name within `{loader.MAX_NAME_LEN}` printable characters; content lists within their caps.", + "* **The fight row** — `events.json` must hold at least one `fight` entry, with weights `> 0`, `min <= max`, and amounts in their per-kind band.", + '* **Cross-references** — `legend` → terrain key, location placements → `locations.json` keys, `starting_weapon`/`starting_armor` → item ids, `boss_monster` → a monster flagged `"boss": true`, `rare_drop_item` → a consumable item id, and `forge_ore_item` → a `material` item id.', "* **Zone tiers** — every zone's tier band must overlap at least one monster tier.", - "* **Dungeon ladder** — every `dungeon_tiers` tier must have a non-boss " - "monster, and that tier's FIRST monster (its fixed rung guardian) must " - "not be `rare`.", + "* **Dungeon ladder** — every `dungeon_tiers` tier must have a non-boss monster, and that tier's FIRST monster (its fixed rung guardian) must not be `rare`.", '* **Exactly one boss** — at most one monster may carry `"boss": true`.', ] return "\n".join(bullets) diff --git a/examples/door-game/understone/world/loader.py b/examples/door-game/understone/world/loader.py index 4ad5a068..84f27ff4 100644 --- a/examples/door-game/understone/world/loader.py +++ b/examples/door-game/understone/world/loader.py @@ -579,6 +579,22 @@ def _decode_zones( tier_hi=hi, ) ) + # Zones must not overlap: zone_for returns the FIRST match, so an overlap + # would silently shadow one zone's tier band on the shared cells. Reject it + # at load so an authored pack can't ship that bug unseen. + for i, first in enumerate(out): + for second in out[i + 1 :]: + if ( + first.x0 <= second.x1 + and second.x0 <= first.x1 + and first.y0 <= second.y1 + and second.y0 <= first.y1 + ): + raise WorldLoadError( + f"world.json zones {first.key!r} and {second.key!r} overlap; " + "give each zone a distinct rectangle (zone_for takes the first " + "match, so an overlap would silently shadow one tier band)" + ) return out diff --git a/examples/door-game/understone/world/packs/README.md b/examples/door-game/understone/world/packs/README.md index 3887a6cf..ce730b8c 100644 --- a/examples/door-game/understone/world/packs/README.md +++ b/examples/door-game/understone/world/packs/README.md @@ -9,9 +9,9 @@ of the pack's JSON files). The slug is the subdirectory name. `understone worlds` discovers the Vale plus every pack here that carries a `world.json`, loads each one, and reports whether it is sound. -The directory ships effectively empty (this README is the placeholder that keeps -it under version control); alternate worlds are added here as they are authored. -To serve one, point the server at it: +This directory ships with one bundled alternate world — **The Cinder Wastes** +(`cinder-wastes/`), an ashen volcanic underworld authored against `AUTHORING.md`. +More are added here as they are written. To serve one, point the server at it: ```bash UNDERSTONE_WORLD=understone/world/packs/ understone diff --git a/examples/door-game/understone/world/packs/cinder-wastes/world.json b/examples/door-game/understone/world/packs/cinder-wastes/world.json index b49bd9b2..22b7af0f 100644 --- a/examples/door-game/understone/world/packs/cinder-wastes/world.json +++ b/examples/door-game/understone/world/packs/cinder-wastes/world.json @@ -92,7 +92,7 @@ }, { "key": "caldera_deep", - "rect": [60, 8, 82, 22], + "rect": [61, 8, 82, 22], "tier_lo": 3, "tier_hi": 5 }