diff --git a/examples/door-game/pyproject.toml b/examples/door-game/pyproject.toml index 86227017..5af54f54 100644 --- a/examples/door-game/pyproject.toml +++ b/examples/door-game/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "understone" -version = "0.8.0" +version = "0.9.0" description = "Understone — a BBS-style ANSI door game served over MCP." requires-python = ">=3.11" license = "Apache-2.0" diff --git a/examples/door-game/tests/test_cli.py b/examples/door-game/tests/test_cli.py index 3cac311a..6f700b9b 100644 --- a/examples/door-game/tests/test_cli.py +++ b/examples/door-game/tests/test_cli.py @@ -183,6 +183,42 @@ def test_cli_newpack_authoring_md_states_color_advisory_and_spawn_walkable( assert "must be on walkable terrain" in manual +def test_cli_newpack_authoring_md_color_roles_generated_from_enum(tmp_path: Path) -> None: + """AUTHORING.md's colour-role vocabulary is generated from the Color enum. + + The v0.9 fix: the assignable roles were hand-listed (and went stale — road + and the per-building roles were missing). They are now generated from + ``Color.assignable()`` — the single source for the overlay-vs-assignable + split — so the manual lists exactly what the Watch can paint and cannot + drift. This asserts the NEW roles appear, that every assignable enum role + appears, and that the non-assignable roles (overlays + DEFAULT) are NOT + offered as author-assignable. + """ + from understone.screen.palette import Color + + dest = tmp_path / "mypack" + cli.cli_newpack(dest, out=StringIO(), err=StringIO()) + manual = (dest / "AUTHORING.md").read_text(encoding="utf-8") + + # A sampling of the new v0.9 roles is offered in the manual, backticked. + for role in ("road", "forest", "lava", "barren", "inn", "shop", "healer"): + assert f"`{role}`" in manual, f"new colour role {role!r} missing from manual" + + # EVERY assignable enum role appears (generated, so the full set is present). + color_section = manual[manual.index("`color` — a palette role string") :].split("###", 1)[0] + for role in Color.assignable(): + assert f"`{role.value}`" in manual, f"assignable role {role.value!r} missing from manual" + + # The non-assignable roles (runtime overlays + the DEFAULT fallback) are NOT + # offered as terrain/location colours. + non_assignable = {c for c in Color} - set(Color.assignable()) + assert Color.DEFAULT in non_assignable # the fallback is not author-pickable + for role in non_assignable: + assert f"`{role.value}`" not in color_section, ( + f"non-assignable role {role.value!r} wrongly offered as author-assignable" + ) + + def test_cli_newpack_authoring_md_has_validate_coverage_split(tmp_path: Path) -> None: """AUTHORING.md honestly separates machine-enforced rules from eyeball-only. diff --git a/examples/door-game/tests/test_package.py b/examples/door-game/tests/test_package.py index 240be671..40506721 100644 --- a/examples/door-game/tests/test_package.py +++ b/examples/door-game/tests/test_package.py @@ -6,4 +6,4 @@ import understone def test_version_present() -> None: - assert understone.__version__ == "0.8.0" + assert understone.__version__ == "0.9.0" diff --git a/examples/door-game/tests/test_watch.py b/examples/door-game/tests/test_watch.py index 1aa610bd..e896222a 100644 --- a/examples/door-game/tests/test_watch.py +++ b/examples/door-game/tests/test_watch.py @@ -8,6 +8,7 @@ Vale live" line that appears only when a Game carries a watch URL. from __future__ import annotations +import re from pathlib import Path from typing import TYPE_CHECKING @@ -20,6 +21,7 @@ from understone.engine.log import Event from understone.engine.rng import GameRNG from understone.game import Game from understone.persistence import Store +from understone.screen.palette import Color from understone.world.loader import load_world if TYPE_CHECKING: @@ -72,8 +74,6 @@ def test_world_payload_legend_is_complete(world: World) -> None: glyphs = {ch for row in rows for ch in row} assert glyphs <= set(legend) # And every legend colour is a real palette colour name (no stray roles). - from understone.screen.palette import Color - valid = {c.value for c in Color} assert set(legend.values()) <= valid @@ -112,6 +112,141 @@ def test_world_payload_carries_reskinned_glyphs(world: World) -> None: assert by_name["The Understone Deep"] == "∩" +# --------------------------------------------------------------------------- +# v0.9 colour-role split — the payload now carries the EXPANDED vocabulary, so +# distinct terrain/building types read by hue on the Watch and not just by glyph. +# These pin the literal fixes: road no longer shares grass's colour, forest no +# longer shares tree's, the town buildings each carry their own role, and the +# Cinder slag is lava (orange), no longer water (blue). +# --------------------------------------------------------------------------- + +CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes" + + +def _terrain_kinds(world: World) -> dict[str, str]: + """Return the distinct terrain kinds in *world* as ``{key: colour role}``. + + ``world.terrain`` is the painted 2-D grid (one ``TerrainDef`` per cell); the + distinct kinds are recovered by deduplicating it on ``key``. Every kind in a + shipped world appears on the map, so this sees all of them. + """ + kinds: dict[str, str] = {} + for row in world.terrain: + for cell in row: + kinds[cell.key] = cell.color + return kinds + + +def _legend_for_terrain_key(world: World, key: str) -> str: + """Return the legend colour the payload carries for terrain ``key``. + + Resolves the terrain key to its glyph, then reads that glyph's colour out of + the built payload's legend — so the assertion is on what the Watch receives, + not on the raw JSON. + """ + payload = watch.build_world_payload(world) + legend = payload["legend"] + assert isinstance(legend, dict) + glyph = next(cell.glyph for row in world.terrain for cell in row if cell.key == key) + return legend[glyph] + + +def test_vale_payload_road_is_not_floor(world: World) -> None: + """REGRESSION (the literal bug the slice fixes): road has its OWN colour. + + Before v0.9 the Vale road shared ``floor`` with grass, so a path was + indistinguishable from open ground on the Watch. The road now carries + ``road``; grass keeps ``floor``; they must differ. + """ + road = _legend_for_terrain_key(world, "road") + grass = _legend_for_terrain_key(world, "grass") + assert road == "road" + assert grass == "floor" + assert road != grass + + +def test_vale_payload_forest_is_not_tree(world: World) -> None: + """REGRESSION: forest has its OWN colour, no longer shared with tree. + + Dense forest scrub used to share ``tree`` with the tree wall, so the two + read identically. Forest now carries ``forest``; tree keeps ``tree``. + """ + forest = _legend_for_terrain_key(world, "forest") + tree = _legend_for_terrain_key(world, "tree") + assert forest == "forest" + assert tree == "tree" + assert forest != tree + + +def test_vale_payload_buildings_carry_distinct_roles(world: World) -> None: + """Each Vale town building rides its own role (inn/shop/healer), not ``town``.""" + payload = watch.build_world_payload(world) + by_name = {loc["name"]: loc["color"] for loc in payload["locations"]} # type: ignore[index,union-attr] + assert by_name["The Sleeping Drake"] == "inn" + assert by_name["Gravel & Sons Outfitters"] == "shop" + assert by_name["The Quiet Shrine"] == "healer" + assert by_name["The Understone Deep"] == "dungeon" + # No two distinct buildings share a colour role. + roles = list(by_name.values()) + assert len(set(roles)) == len(roles) + + +def test_cinder_payload_slag_is_lava_not_water() -> None: + """The Cinder slag carries ``lava`` (orange), never ``water`` (blue) again. + + This is the Cinder half of the bug: molten slag shared ``water``, so the + lava rendered BLUE on the Watch. After the remap the legend carries ``lava`` + and ``water`` appears NOWHERE in the Cinder payload (no water in this world). + """ + cinder = load_world(CINDER) + payload = watch.build_world_payload(cinder) + legend = payload["legend"] + assert isinstance(legend, dict) + assert _legend_for_terrain_key(cinder, "slag") == "lava" + assert "water" not in legend.values() + + +def test_cinder_payload_carries_expanded_roles() -> None: + """The Cinder terrain reads by hue: ash→barren, basalt→road, cinder→scrub. + + Cinder-fields use ``scrub`` (dusky ember-brown), NOT ``forest`` (green) — + a volcanic waste must not render as lush woods. ``forest`` is for green + worlds; ``scrub`` is its barren counterpart. + """ + cinder = load_world(CINDER) + assert _legend_for_terrain_key(cinder, "ash") == "barren" + assert _legend_for_terrain_key(cinder, "basalt") == "road" + assert _legend_for_terrain_key(cinder, "cinder") == "scrub" + legend = watch.build_world_payload(cinder)["legend"] + assert isinstance(legend, dict) + assert "forest" not in legend.values() # no green woods in a volcanic waste + # Obsidian spire reuses the wall role (a rock barrier), same as caldera. + assert _legend_for_terrain_key(cinder, "spire") == "wall" + assert _legend_for_terrain_key(cinder, "caldera") == "wall" + + +def test_both_worlds_terrain_roles_are_distinct_per_world() -> None: + """No two DISTINCT terrain types share a colour role within a world. + + The point of the slice: after the remap each terrain kind reads by its own + hue. (A role MAY be shared by two types that are deliberately the same + barrier — spire/caldera both ``wall`` in Cinder — so this checks distinct + KEYS that map to the same role are only the intended wall pair.) + """ + for world_dir, allowed_shared in ( + (PACK, set()), + (CINDER, {("caldera", "spire")}), + ): + w = load_world(world_dir) + by_role: dict[str, list[str]] = {} + for key, role in _terrain_kinds(w).items(): + by_role.setdefault(role, []).append(key) + for role, keys in by_role.items(): + if len(keys) > 1: + pair = tuple(sorted(keys)) + assert pair in allowed_shared, f"unexpected shared role {role!r}: {keys}" + + # --------------------------------------------------------------------------- # State payload (dynamic) # --------------------------------------------------------------------------- @@ -346,3 +481,57 @@ def test_watch_html_has_day_phase_machinery() -> None: assert ".map-frame.night" in html assert ".map-frame.twilight" in html assert "Noto Sans Mono" in html + + +# --------------------------------------------------------------------------- +# PALETTE completeness — the v0.9 invariant that kills the "silent fallback" +# bug class. The road bug existed because a Color role with no hex in the JS +# PALETTE map fell back to default; this pins that EVERY role has a hex. +# --------------------------------------------------------------------------- + + +def _watch_palette_keys() -> set[str]: + """Parse the JS ``var PALETTE = { ... }`` map out of WATCH_HTML, return its keys. + + The map uses bare (unquoted) JS identifier keys — ``road: "#b89a6a",`` — so + this slices the object literal and collects every ``key:`` token. Keeping the + parse here (not a hard-coded list) means the test reads whatever the page + actually ships, so a typo'd or dropped key surfaces as a missing role. + """ + html = watch.WATCH_HTML + start = html.index("var PALETTE = {") + body = html[start : html.index("};", start)] + # Each entry is `: ""`; capture the identifier before the colon. + return set(re.findall(r"(\w+)\s*:\s*\"#", body)) + + +def test_watch_palette_covers_every_color_role() -> None: + """EVERY Color enum value has an entry in the JS PALETTE map — no fallbacks. + + This is the literal fix for the road bug: a shipped role with no hex paints + as ``default`` silently. Asserting ``{c.value} <= palette_keys`` means adding + a Color without a Watch hex trips here instead of shipping a grey/green road. + """ + palette_keys = _watch_palette_keys() + roles = {c.value for c in Color} + missing = roles - palette_keys + assert not missing, f"Color roles with no PALETTE hex (silent fallback): {sorted(missing)}" + + +def test_watch_palette_distinct_new_terrain_hexes() -> None: + """The expanded terrain roles carry DISTINCT hexes (the point of the slice). + + A guard that the seven new roles didn't accidentally collapse onto one hex + (which would re-introduce the very "two types, one colour" bug v0.9 fixes). + Parsed straight from the shipped map. + """ + html = watch.WATCH_HTML + start = html.index("var PALETTE = {") + body = html[start : html.index("};", start)] + pairs = dict(re.findall(r"(\w+)\s*:\s*\"(#[0-9a-fA-F]{6})\"", body)) + new_roles = ["road", "forest", "lava", "barren", "inn", "shop", "healer"] + hexes = [pairs[r] for r in new_roles] + assert all(r in pairs for r in new_roles), "a new v0.9 role is missing its hex" + assert len(set(hexes)) == len(hexes), f"new roles share a hex: {hexes}" + # The molten role must NOT reuse water's blue (the Cinder slag bug). + assert pairs["lava"] != pairs["water"] diff --git a/examples/door-game/understone/__init__.py b/examples/door-game/understone/__init__.py index fbfb2601..caa79e21 100644 --- a/examples/door-game/understone/__init__.py +++ b/examples/door-game/understone/__init__.py @@ -1,3 +1,3 @@ """Understone — a BBS-style ANSI door game served over MCP.""" -__version__ = "0.8.0" +__version__ = "0.9.0" diff --git a/examples/door-game/understone/cli.py b/examples/door-game/understone/cli.py index 708264b1..f6b981b0 100644 --- a/examples/door-game/understone/cli.py +++ b/examples/door-game/understone/cli.py @@ -203,6 +203,7 @@ def build_authoring_md() -> str: """ md = _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands()) md = md.replace("{{PALETTE}}", _render_palette()) + md = md.replace("{{COLOR_ROLES}}", _render_color_roles()) return md.replace("{{VALIDATE_COVERAGE}}", _render_validate_coverage()) @@ -293,6 +294,24 @@ def _render_palette() -> str: ) +def _render_color_roles() -> str: + """Render the author-assignable colour roles, generated from the Color enum. + + The Watch knows how to paint exactly the roles in ``screen.palette.Color``; + ``Color.assignable()`` is the single source for which of those an author may + put on terrain or a location (the runtime overlay roles an actor/item wears, + and the DEFAULT fallback, are filtered out there). Generated from the enum, + so the documented vocabulary can never drift from what the Watch can + actually colour — the same can't-drift discipline as the bands and the + safe-glyph palette. ``color`` itself stays advisory: the loader does not + validate it, so a typo is harmless and an unknown role just paints as the + default; these are simply the roles the Watch recognises. + """ + from understone.screen.palette import Color + + return ", ".join(f"`{role.value}`" for role in Color.assignable()) + + def _render_validate_coverage() -> str: """Render the list of rules the loader actually enforces, generated from it. @@ -403,11 +422,13 @@ An object whose keys are the single-character legend symbols used in the map. * `encounter_rate` — `0.0`..`1.0`, the per-step chance a walk rolls the event table on this terrain. * `color` — a palette role string. It is **advisory and not validated**: the - loader stores it but the v1 text renderer draws glyphs only, so any string - loads and an unrecognised role simply maps to the default at render time. The - roles a future colour renderer will recognise are `floor`, `wall`, `water`, - `tree`, `town`, and `dungeon`; pick the closest, but a typo here is harmless, - not a load error. + loader stores it but the text frame draws glyphs only (it is monochrome), so + any string loads and an unrecognised role simply maps to the default at render + time. Where colour DOES show is the live Watch page, which paints each role a + distinct hue. The roles the Watch knows how to paint — pick the closest fit — + are: {{COLOR_ROLES}}. A typo here is harmless, not a load error; it just + paints as the default. The four runtime overlay colours (the hero, rival + players, monsters, dropped items) are set by the engine, not assignable here. ### `monsters.json` diff --git a/examples/door-game/understone/screen/palette.py b/examples/door-game/understone/screen/palette.py index 87a092a0..bf5b4ef6 100644 --- a/examples/door-game/understone/screen/palette.py +++ b/examples/door-game/understone/screen/palette.py @@ -1,8 +1,10 @@ """Colour vocabulary for cells. -Colours are *stored* on cells but never rendered in v1 — the text -renderer emits glyphs only. The enum exists so a future ANSI renderer can -map roles to SGR codes without touching the grid model. +Colours are *stored* on cells and rendered by the live Watch page, which maps +each role to a hue (see ``watch.PALETTE``). The text frame renderer stays +monochrome — it emits glyphs only — so a cell's colour rides the grid model +untouched until a colour-aware renderer (the Watch today, an ANSI terminal +later) reads it. """ from __future__ import annotations @@ -11,7 +13,15 @@ from enum import Enum class Color(Enum): - """Semantic colour roles for grid cells.""" + """Semantic colour roles for grid cells. + + One global vocabulary, shared by every world — there are no per-world or + per-theme palettes. Roles are split into two families: the runtime overlay + colours an actor or item wears (``PLAYER``/``OTHER_PLAYER``/``MONSTER``/ + ``ITEM``) and the author-assignable terrain/location roles a pack paints its + map with (everything else). A future colour renderer maps each role to a + hue; the Watch already does (see ``watch.PALETTE``). + """ DEFAULT = "default" WALL = "wall" @@ -24,3 +34,32 @@ class Color(Enum): TREE = "tree" TOWN = "town" DUNGEON = "dungeon" + # Expanded terrain/location roles (v0.9) — so distinct types read by hue and + # not only by glyph. ROAD splits paths off FLOOR; FOREST is lush dense + # vegetation; SCRUB is its barren counterpart — rough, non-lush dense terrain + # (volcanic cinder, desert scrub) that must NOT read as green woods; LAVA + # gives molten ground its own orange (no longer mis-sharing WATER's blue); + # BARREN gives open wasteland ground a taupe; INN/SHOP/HEALER give each town + # building its own hue (TOWN stays as a generic fallback). + ROAD = "road" + FOREST = "forest" + SCRUB = "scrub" + LAVA = "lava" + BARREN = "barren" + INN = "inn" + SHOP = "shop" + HEALER = "healer" + + @classmethod + def assignable(cls) -> list[Color]: + """The roles a pack may paint terrain or a location with. + + One source of truth for the overlay-vs-assignable split. Excludes the + runtime overlay colours an actor/item wears (``PLAYER``/ + ``OTHER_PLAYER``/``MONSTER``/``ITEM``) and the ``DEFAULT`` fallback — + none of which an author assigns. Consumers (the authoring manual and + its test) read this so the documented vocabulary can never drift from + the enum. Returned in definition order. + """ + overlay = {cls.DEFAULT, cls.PLAYER, cls.OTHER_PLAYER, cls.MONSTER, cls.ITEM} + return [role for role in cls if role not in overlay] diff --git a/examples/door-game/understone/watch.py b/examples/door-game/understone/watch.py index b726f06f..1b3bd9b4 100644 --- a/examples/door-game/understone/watch.py +++ b/examples/door-game/understone/watch.py @@ -380,8 +380,11 @@ _WATCH_HTML_TEMPLATE = """\ } } - // Palette colour-name -> phosphor-tinted hex. Mirrors understone.screen.palette - // Color values; the base map is coloured from this, never from the server. + // Palette colour-name -> phosphor-tinted hex. ONE global map, shared by every + // world (no per-world or per-theme palettes). Mirrors understone.screen.palette + // Color values 1:1 — a guard test asserts every Color role has an entry here, + // so a shipped role can never silently fall back to default. The base map is + // coloured from this, never from the server. var PALETTE = { default: "#7dffa0", wall: "#5a6b60", @@ -393,7 +396,16 @@ _WATCH_HTML_TEMPLATE = """\ water: "#4aa6c8", tree: "#3fae6a", town: "#ffd089", - dungeon: "#c98bff" + dungeon: "#c98bff", + // v0.9 expanded terrain/location roles, chosen for hue separation: + road: "#b89a6a", + forest: "#6a9f3f", + scrub: "#9c6038", + lava: "#ff7a3c", + barren: "#9a8b7a", + inn: "#ff9d4d", + shop: "#ffd24d", + healer: "#5fd6b0" }; function colorFor(name) { diff --git a/examples/door-game/understone/world/data/locations.json b/examples/door-game/understone/world/data/locations.json index 4bf51862..040083ca 100644 --- a/examples/door-game/understone/world/data/locations.json +++ b/examples/door-game/understone/world/data/locations.json @@ -3,7 +3,7 @@ "kind": "inn", "name": "The Sleeping Drake", "glyph": "⌂", - "color": "town", + "color": "inn", "actions": ["rest", "gamble", "leave"], "flavor": [ "Lamplight pools on worn oak tables.", @@ -16,7 +16,7 @@ "kind": "shop", "name": "Gravel & Sons Outfitters", "glyph": "$", - "color": "town", + "color": "shop", "actions": ["buy", "sell", "forge", "leave"], "flavor": [ "Racks of steel and leather line the walls.", @@ -30,7 +30,7 @@ "kind": "healer", "name": "The Quiet Shrine", "glyph": "✚", - "color": "town", + "color": "healer", "actions": ["heal", "leave"], "flavor": [ "Incense curls beneath a still blue flame.", diff --git a/examples/door-game/understone/world/data/terrain.json b/examples/door-game/understone/world/data/terrain.json index 6fb0d25d..13b7c2a4 100644 --- a/examples/door-game/understone/world/data/terrain.json +++ b/examples/door-game/understone/world/data/terrain.json @@ -25,14 +25,14 @@ "glyph": "=", "walkable": true, "encounter_rate": 0.02, - "color": "floor" + "color": "road" }, "f": { "key": "forest", "glyph": "↑", "walkable": true, "encounter_rate": 0.25, - "color": "tree" + "color": "forest" }, "#": { "key": "wall", diff --git a/examples/door-game/understone/world/packs/cinder-wastes/locations.json b/examples/door-game/understone/world/packs/cinder-wastes/locations.json index 5f341ccc..6da3c2d1 100644 --- a/examples/door-game/understone/world/packs/cinder-wastes/locations.json +++ b/examples/door-game/understone/world/packs/cinder-wastes/locations.json @@ -3,7 +3,7 @@ "kind": "inn", "name": "The Forge-Rest", "glyph": "⌂", - "color": "town", + "color": "inn", "actions": ["rest", "gamble", "leave"], "flavor": [ "Heat-bricked walls hold back the ashfall outside.", @@ -16,7 +16,7 @@ "kind": "shop", "name": "The Slag Market", "glyph": "$", - "color": "town", + "color": "shop", "actions": ["buy", "sell", "forge", "leave"], "flavor": [ "Stalls of cooled obsidian and scavenged plate crowd the stone.", @@ -30,7 +30,7 @@ "kind": "healer", "name": "The Ember Shrine", "glyph": "✚", - "color": "town", + "color": "healer", "actions": ["heal", "leave"], "flavor": [ "A still blue pilot-flame burns at the heart of the shrine.", diff --git a/examples/door-game/understone/world/packs/cinder-wastes/terrain.json b/examples/door-game/understone/world/packs/cinder-wastes/terrain.json index 805d1f3c..ffadd9ab 100644 --- a/examples/door-game/understone/world/packs/cinder-wastes/terrain.json +++ b/examples/door-game/understone/world/packs/cinder-wastes/terrain.json @@ -4,35 +4,35 @@ "glyph": "░", "walkable": true, "encounter_rate": 0.1, - "color": "floor" + "color": "barren" }, "A": { "key": "spire", "glyph": "▲", "walkable": false, "encounter_rate": 0.0, - "color": "tree" + "color": "wall" }, "~": { "key": "slag", "glyph": "≈", "walkable": false, "encounter_rate": 0.0, - "color": "water" + "color": "lava" }, "=": { "key": "basalt", "glyph": "=", "walkable": true, "encounter_rate": 0.02, - "color": "floor" + "color": "road" }, "c": { "key": "cinder", "glyph": "▒", "walkable": true, "encounter_rate": 0.25, - "color": "tree" + "color": "scrub" }, "#": { "key": "caldera",