feat(examples): Understone v0.9 — colour roles for every object type

Graphics polish: distinct terrain and structures now read by COLOUR on the
Watch, not only by glyph. One unified palette, shared by every world — the
fix is to grow the set of distinct object-type roles, not to fork per-world.

- Roads were the tell: road shared the "floor" green with grass, so a path
  vanished into the meadow on the lobby TV. Likewise forest shared "tree",
  the three town buildings all shared "town", and the Cinder Wastes' molten
  slag borrowed "water" and rendered BLUE. Each is now its own role: road
  (stone), forest (lush green) with scrub (its barren ember-brown
  counterpart for volcanic/desert dense terrain that must NOT read as
  woods), lava (molten orange), barren (wasteland taupe), and inn/shop/
  healer split out of the generic town.
- Both worlds remap onto the shared vocabulary; in each, no two distinct
  terrain/building types share a colour. A live render caught the Cinder
  cinder-fields rendering green under the generic "forest" role — hence the
  scrub role, so the volcanic waste reads warm. The text frame renderer
  stays monochrome (it never read colour), so frames and goldens are
  untouched — this is Watch-only.
- The bug class is now closed by construction: a test asserts the Watch
  PALETTE carries a hex for EVERY Color role, so a role can never ship
  unpaintable and silently fall back (which is exactly how road hid).
- Color.assignable() is the single source for the overlay-vs-assignable
  split (runtime actor/item colours and the DEFAULT fallback are not
  author-pickable); the authoring manual's colour vocabulary generates
  from it, so it can't drift.

Tests 373 -> 382. floor/tree/forest are three greens kept deliberately
distinct (forest is olive-hued); verified on a real render along with the
scrub fix.
This commit is contained in:
Patrick Buckley
2026-06-12 22:38:13 -07:00
parent 65e7b404bc
commit 917e391b1f
12 changed files with 327 additions and 30 deletions
+1 -1
View File
@@ -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"
+36
View File
@@ -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.
+1 -1
View File
@@ -6,4 +6,4 @@ import understone
def test_version_present() -> None:
assert understone.__version__ == "0.8.0"
assert understone.__version__ == "0.9.0"
+191 -2
View File
@@ -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 `<ident>: "<hex>"`; 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"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.8.0"
__version__ = "0.9.0"
+26 -5
View File
@@ -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`
@@ -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]
+15 -3
View File
@@ -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) {
@@ -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.",
@@ -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",
@@ -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.",
@@ -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",