feat(examples): Understone v0.6 — UTF-8 graphics and the width discipline

The look of the next age — the modern equivalent of the ASCII->CP437 leap.
Full Unicode is available now, but the whole stack (text frames, golden
tests, the Watch's 1ch grid) assumes one glyph = one column, so the
enabling piece is a WIDTH RULE, not the glyphs themselves.

- textwidth.is_grid_safe: one code point, printable, East-Asian width not
  Wide/Fullwidth, no combining/format/control category. This is the
  one-glyph-one-column contract. Ambiguous-width glyphs are ACCEPTED on
  purpose — they ARE CP437 (the wall, the club-tree, the up-arrow forest)
  and render single-column on the Western-monospace metrics every surface
  uses; only genuinely double-width runes are barred. The loader enforces
  it on every map glyph; the player-name/free-text sanitizer enforces the
  same rule (the narrow ledger), so a wide name can't shear a frame.
- Re-skin: water ~ -> ≋, inn -> ⌂, healer -> ✚, dungeon mouth -> ∩, and
  the other adventurer -> ☻ (CP437's own player glyph). The colour field
  the renderer has carried unused since v0.1 now has a second consumer.
- Texture variants: grass and water vary by a deterministic per-coordinate
  hash, rendered identically in the Python frame builder and the Watch's
  JS. The two are kept in lockstep by shared hash constants + an agreement
  test that replays the JS arithmetic and asserts it equals the Python
  output for every variant over a grid — not a comment-coupled copy.
- Watch glow-up: a Noto Sans Mono font stack and a UTC-hour day/night tint
  (the Vale darkens at dusk on the lobby TV).
- The curated SAFE_PALETTE is enforced author-usable: a test asserts no
  palette glyph collides with the reserved player markers, so AUTHORING's
  generated appendix can't advertise a glyph the loader would reject.
- Resume is identity-preserving: an existing character resumes by exact
  stored name without re-validating the width rule (which governs creation
  only) — resume must never lock anyone out.

Tests 231 -> 283; width edges (CJK/emoji/combining/fullwidth), the
Python<->JS lockstep, the palette/reserved guard, and resume-vs-create all
pinned and revert-verified.
This commit is contained in:
Patrick Buckley
2026-06-12 19:37:52 -07:00
parent 08d46f086f
commit b76b2a98d0
20 changed files with 850 additions and 56 deletions
+5 -3
View File
@@ -118,7 +118,7 @@ http://127.0.0.1:8077/watch
(the host and port follow `UNDERSTONE_HOST` / `UNDERSTONE_PORT`). It is a
period **CRT spectator console** — a green-and-amber phosphor map of the whole
world with every adventurer's `@` marker, a live **Understone Herald** feed, the
world with every adventurer's `` marker, a live **Understone Herald** feed, the
**Hall of Legends**, and a roster of who is currently abroad. It refreshes every
couple of seconds; if it loses contact it dims and reads `SIGNAL LOST` until the
server returns.
@@ -130,7 +130,7 @@ keeping with the rest of this easter-egg server (see the safety note below), so
treat the page as you would the MCP endpoint itself.
> _Screenshot: the Watch console — a phosphor-green overworld map with amber
> `@` markers, the Herald feed and Hall of Legends down the right-hand rail.
> `` markers, the Herald feed and Hall of Legends down the right-hand rail.
> (Image placeholder; run the server and open the URL to see it live.)_
When the Watch is up, the `door_join` welcome and the `door_help` manual both
@@ -165,7 +165,9 @@ hardened loader the server uses and either prints a summary ending **"This pack
is sound. The door stands open."** or fails with one precise line naming the
file, the row, and the field at fault.
Packs are validated **hard** at load: glyphs may not collide with the frame's
Packs are validated **hard** at load: every map glyph must render as exactly
one terminal column (no fullwidth runes, no emoji, no combining marks — the
frames are box-drawing rectangles) and may not collide with the frame's
box-drawing lines or the player markers, dimensions and counts are bounded,
display names are length-checked, and every cross-reference (a legend
character, a starting item, the boss monster, a dungeon tier) must resolve.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "understone"
version = "0.5.0"
version = "0.6.0"
description = "Understone — a BBS-style ANSI door game served over MCP."
requires-python = ">=3.11"
license = "Apache-2.0"
+21
View File
@@ -129,6 +129,27 @@ def test_cli_newpack_authoring_md_renders_live_band(tmp_path: Path) -> None:
assert "daily_turns" in manual
def test_cli_newpack_authoring_md_has_width_rule_and_live_palette(tmp_path: Path) -> None:
"""AUTHORING.md documents the one-column rule and renders the live palette.
The width section states the Western-monospace assumption, and the safe
palette is generated from ``textwidth.SAFE_PALETTE`` (same can't-drift
pattern as the bands table) — every glyph appears, in a backticked cell.
"""
from understone.engine.textwidth import SAFE_PALETTE
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "## Glyph width" in manual
assert "exactly one terminal column" in manual
assert "Western monospace" in manual # the stated assumption
assert "Safe glyph palette" in manual
for glyph in SAFE_PALETTE:
assert f"`{glyph}`" in manual, f"palette glyph {glyph!r} missing from manual"
def test_cli_newpack_refuses_non_empty_dir(tmp_path: Path) -> None:
dest = tmp_path / "occupied"
dest.mkdir()
+140 -1
View File
@@ -24,6 +24,7 @@ Negative-test discipline (turn guard and bestow cap):
from __future__ import annotations
import unicodedata
from pathlib import Path
import pytest
@@ -89,6 +90,29 @@ def test_look_overworld_has_frame(tmp_path: Path, clock: object) -> None:
assert len(out) < 2048
def test_overworld_frame_textured_borders_intact(tmp_path: Path, clock: object) -> None:
"""The textured overworld frame keeps square borders and a single player marker.
Structural discipline for the v0.6 texture: variants change the GLYPHS but
must never change the geometry. The box rows are uniform width, exactly one
'@' is painted, and the grass field shows more than one variant in a row
(the deterministic stipple, not a flat sheet of '.').
"""
game = _game(tmp_path, clock)
game.join("Brandr")
frame = game.look("Brandr")
lines = frame.split("\n")
# Box rows: top border + VIEW_H grid rows + bottom border, all equal width.
box = [ln for ln in lines if ln and ln[0] in "┌│└"]
widths = {len(ln) for ln in box}
assert len(widths) == 1, f"textured frame rows ragged: {widths}"
# Exactly one player marker, regardless of the surrounding texture.
assert frame.count("@") == 1
# The grass texture varies: a body row carries at least two of . , '
body = [ln for ln in lines if ln.startswith("")]
assert any(len({ch for ch in ln if ch in ".,'"}) >= 2 for ln in body)
def test_look_in_menu_shows_location(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Brandr")
@@ -249,7 +273,7 @@ def test_shared_world_other_player_marker(tmp_path: Path, clock: object) -> None
brandr = game.players["Brandr"]
sig.x, sig.y = brandr.x + 1, brandr.y
out = game.look("Brandr")
assert "&" in out # the other player shows as '&'
assert "" in out # the other player shows as ''
# ---------------------------------------------------------------------------
@@ -462,6 +486,121 @@ def test_join_accepts_max_length_name(tmp_path: Path, clock: object) -> None:
assert name in game.players
# ---------------------------------------------------------------------------
# Narrow-ledger width rule (the _sanitize one-column clause, v0.6)
#
# Names/reasons/mail render inside fixed-width frames and tables, so a glyph
# that does not fit a single column would shove a column out of true. The
# sanitizer rejects wide runes and combining marks; a printable-but-wide name
# gets the dedicated narrow-ledger refusal, not the control-char "runes" line.
# ---------------------------------------------------------------------------
def test_join_rejects_wide_cjk_name(tmp_path: Path, clock: object) -> None:
"""A CJK ideograph name is refused with the narrow-ledger line; nothing written."""
game = _game(tmp_path, clock)
out = game.join("")
assert "columns are narrow" in out
assert game.players == {}
assert game.events == []
def test_join_rejects_emoji_name(tmp_path: Path, clock: object) -> None:
"""An emoji in a name (🌲x) is wide and refused with the narrow-ledger line."""
game = _game(tmp_path, clock)
out = game.join("🌲x")
assert "columns are narrow" in out
assert game.players == {}
def test_join_rejects_fullwidth_name(tmp_path: Path, clock: object) -> None:
"""A fullwidth Latin letter () is two columns and refused."""
game = _game(tmp_path, clock)
out = game.join("")
assert "columns are narrow" in out
assert game.players == {}
def test_join_rejects_combining_mark_name(tmp_path: Path, clock: object) -> None:
"""A name with a combining mark (decomposed accent) is refused as wide.
The name is normalised to NFD so the 'o' carries a separate U+0308
combining diaeresis — a zero-width code point that desynchronises the
column count. Built explicitly so the source encoding cannot mask it.
"""
game = _game(tmp_path, clock)
decomposed = unicodedata.normalize("NFD", "Bj\u00f6rn")
assert any(unicodedata.combining(ch) for ch in decomposed) # genuinely NFD
out = game.join(decomposed)
assert "columns are narrow" in out
assert game.players == {}
def test_join_accepts_composed_latin_name(tmp_path: Path, clock: object) -> None:
"""A precomposed Latin accent (NFC name) is all single-column and accepted."""
game = _game(tmp_path, clock)
composed = unicodedata.normalize("NFC", "Bj\u00f6rn")
game.join(composed)
assert composed in game.players
def _seed_wide_named_player(db: Path, clock: object, wide_name: str) -> None:
"""Write a stored adventurer whose name is a now-illegal wide rune.
Bypasses ``join`` (which would refuse a wide name at creation) by upserting
a Player row straight through the Store, so the fixture stands in for a save
that predates the narrow-ledger rule. Built by renaming a legitimately-
created hero so every other field stays valid.
"""
from dataclasses import replace
world = load_world(PACK)
seed = Store(db)
game = Game(world, seed, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
game.join("Brandr")
base = game.players["Brandr"]
seed.upsert_player(replace(base, name=wide_name))
seed.commit()
seed.close()
def test_join_resumes_stored_wide_name(tmp_path: Path, clock: object) -> None:
"""An existing adventurer with a wide-rune name resumes \u2014 identity is never re-gated.
Resume keys off the exact stored name BEFORE the sanitizer, so a character
whose name predates the narrow-ledger rule is welcomed back rather than
locked out. This is the resume-by-exact-name invariant.
"""
db = tmp_path / "game.db"
wide = "\u9f8d"
_seed_wide_named_player(db, clock, wide)
world = load_world(PACK)
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
out = game.join(wide)
assert "Welcome back" in out # resumed, not refused
assert "columns are narrow" not in out
assert wide in game.players
def test_join_still_refuses_new_wide_name(tmp_path: Path, clock: object) -> None:
"""Creation is still gated: a NEW wide name with no stored row is refused.
The resume bypass is exact-name only; a wide name that matches no stored
adventurer falls through to the creation gate and gets the narrow-ledger
refusal, with nothing written.
"""
db = tmp_path / "game.db"
# Seed one wide-named save, then try to CREATE a different wide name.
_seed_wide_named_player(db, clock, "\u9f8d")
world = load_world(PACK)
game = Game(world, Store(db), clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
out = game.join("\u7363") # a different wide rune \u2014 no stored row for it
assert "columns are narrow" in out
assert "\u7363" not in game.players
def test_bestow_rejects_newline_reason_no_persist(tmp_path: Path, clock: object) -> None:
"""A newline-embedded bestow reason is refused; no event, pool unchanged."""
game = _game(tmp_path, clock)
@@ -166,8 +166,8 @@ def test_mcp_end_to_end(live_server: str) -> None:
assert "@" in look_before
assert "" in look_before and "" in look_before
# Shared-world proof: after player two joins next door, player one sees '&'.
assert "&" in obs["look_one_after"]
# Shared-world proof: after player two joins next door, player one sees ''.
assert "" in obs["look_one_after"]
# And the leaderboard lists both adventurers (one process, one world).
assert "Brandr" in obs["rank"]
assert "Sigrun" in obs["rank"]
+1 -1
View File
@@ -6,4 +6,4 @@ import understone
def test_version_present() -> None:
assert understone.__version__ == "0.5.0"
assert understone.__version__ == "0.6.0"
+60
View File
@@ -0,0 +1,60 @@
"""Deterministic terrain texturing (understone.screen.texture).
Pins the contract the Watch JS mirrors: a textured glyph is a pure function of
its cell coordinate (stable per cell), an un-listed glyph is returned
untouched, and the selection formula is ``(x * _HASH_X + y * _HASH_Y) % n``
derived from the module's hash constants. The formula is asserted against those
constants so a retune moves the test with it and a drift is caught.
"""
from __future__ import annotations
from understone.screen.texture import _HASH_X, _HASH_Y, VARIANTS, textured
def test_untextured_glyph_is_unchanged() -> None:
"""A glyph with no VARIANTS row passes through verbatim (actors, walls)."""
for ch in "█@☻⌂$":
assert textured(ch, 3, 7) == ch
def test_same_coord_same_variant() -> None:
"""Texturing is position-only and stable: one cell always picks one glyph."""
first = textured(".", 12, 5)
for _ in range(5):
assert textured(".", 12, 5) == first
def test_variant_is_always_in_the_row() -> None:
"""Every selected glyph is one of the declared variants for its base."""
choices = VARIANTS["."]
for x in range(20):
for y in range(20):
assert textured(".", x, y) in choices
def test_a_row_uses_more_than_one_variant() -> None:
"""Across a row the hash spreads — the texture is not a single repeated glyph."""
seen = {textured(".", x, 0) for x in range(len(VARIANTS["."]) * 4)}
assert len(seen) > 1
def test_formula_matches_the_hash_constants() -> None:
"""The selection index is (x * _HASH_X + y * _HASH_Y) % len — the JS twin's formula.
Derived from the live ``_HASH_X`` / ``_HASH_Y`` constants (not the literal
31/17) and checked against the live VARIANTS rows, so it stays a formula
test that tracks a retune rather than a snapshot a table or constant edit
could silently invalidate.
"""
for base, choices in VARIANTS.items():
n = len(choices)
for x, y in [(0, 0), (1, 0), (0, 1), (12, 5), (7, 13), (255, 255)]:
assert textured(base, x, y) == choices[(x * _HASH_X + y * _HASH_Y) % n]
def test_origin_cell_is_the_base_glyph() -> None:
"""Cell (0,0) hashes to index 0, which is the base glyph (variants[0])."""
for base, choices in VARIANTS.items():
assert textured(base, 0, 0) == choices[0]
assert choices[0] == base
@@ -0,0 +1,81 @@
"""The one-glyph-one-column grid contract (understone.engine.textwidth).
Pins the accept/reject boundary of :func:`is_grid_safe` and proves every
:data:`SAFE_PALETTE` entry clears it. The acceptances include the
East-Asian-Width *Ambiguous* CP437 glyphs the game leans on (``█ ♣ ↑ ∩ ≈ ★``),
which render single-column under the Western monospace our surfaces use; the
rejections are the genuinely double-width and zero-width classes that tear a
frame.
"""
from __future__ import annotations
import unicodedata
import pytest
from understone.engine.textwidth import SAFE_PALETTE, is_grid_safe
from understone.world.loader import RESERVED_GLYPHS
# Single-column glyphs that must be admitted: plain ASCII, a Latin accent that
# is one composed code point, and the Ambiguous-width CP437 set the re-skin uses.
_ACCEPTED = ["a", "Z", "ö", "", "", "", "", "", "", "", ".", "$", " "]
# Must be rejected, with the reason each one trips the gate.
_REJECTED = {
"": "wide CJK ideograph (EAW=W) — two columns",
"🌲": "emoji (EAW=W) — two columns",
"": "fullwidth Latin A (EAW=F) — two columns",
"": "decomposed e + combining acute — two code points",
"́": "a lone combining acute — zero width",
"👨‍👩": "ZWJ sequence — multiple code points",
"ab": "two characters",
"": "empty string",
"\t": "a control character",
}
@pytest.mark.parametrize("ch", _ACCEPTED)
def test_is_grid_safe_accepts(ch: str) -> None:
assert is_grid_safe(ch) is True
@pytest.mark.parametrize("text", list(_REJECTED), ids=list(_REJECTED.values()))
def test_is_grid_safe_rejects(text: str) -> None:
assert is_grid_safe(text) is False
def test_safe_palette_is_all_grid_safe() -> None:
"""Every curated palette glyph clears the gate — the appendix can't ship a dud."""
bad = [g for g in SAFE_PALETTE if not is_grid_safe(g)]
assert bad == [], f"palette has non-grid-safe glyphs: {bad}"
def test_safe_palette_has_no_reserved_glyphs() -> None:
"""No palette glyph is a loader-reserved marker — the 'author-usable' promise.
The appendix tells a pack author to pull any palette glyph for terrain,
structures, or actors, but the loader rejects the box-drawing frame lines
and the '@'/'' player markers (``loader.RESERVED_GLYPHS``). A palette entry
that is also reserved would hand the author a glyph that load-fails — the
exact doc-vs-enforcement trap. Guarding the intersection keeps "all tested
safe AND author-usable" enforced, not merely asserted on width.
"""
collisions = set(SAFE_PALETTE) & RESERVED_GLYPHS
assert collisions == set(), f"palette offers loader-reserved glyphs: {sorted(collisions)}"
def test_safe_palette_has_no_duplicates() -> None:
"""The palette is a set in spirit; a dupe would be an authoring slip."""
assert len(SAFE_PALETTE) == len(set(SAFE_PALETTE))
def test_ambiguous_width_glyphs_are_accepted() -> None:
"""Document the load-bearing call: EAW=Ambiguous is admitted, not barred.
These are the CP437 glyphs the game depends on; if a future tightening
barred Ambiguous, the whole re-skin would vanish from the map.
"""
for ch in "█♣↑∩≈★":
assert unicodedata.east_asian_width(ch) == "A"
assert is_grid_safe(ch) is True
+92 -1
View File
@@ -86,11 +86,32 @@ def test_world_payload_locations_present(world: World) -> None:
by_name = {loc["name"]: loc for loc in locations}
# The dungeon mouth rides in the locations overlay with its glyph + colour.
deep = by_name["The Understone Deep"]
assert deep["glyph"] == ">"
assert deep["glyph"] == ""
assert deep["color"] == "dungeon"
assert (deep["x"], deep["y"]) == (70, 12)
def test_world_payload_carries_reskinned_glyphs(world: World) -> None:
"""The v0.6 re-skin reaches the Watch: ≋ water in the rows, ⌂/✚/∩ buildings.
Water rides the base terrain (glyph_rows + legend); the buildings ride the
locations overlay. If a glyph reverts, the live map drifts from the frames.
"""
payload = watch.build_world_payload(world)
rows = payload["glyph_rows"]
assert isinstance(rows, list)
glyphs = {ch for row in rows for ch in row}
assert "" in glyphs # water in the base map
assert "~" not in glyphs # the old water glyph is gone
legend = payload["legend"]
assert isinstance(legend, dict)
assert "" in legend
by_name = {loc["name"]: loc["glyph"] for loc in payload["locations"]} # type: ignore[index,union-attr]
assert by_name["The Sleeping Drake"] == ""
assert by_name["The Quiet Shrine"] == ""
assert by_name["The Understone Deep"] == ""
# ---------------------------------------------------------------------------
# State payload (dynamic)
# ---------------------------------------------------------------------------
@@ -255,3 +276,73 @@ def test_help_omits_watch_line_when_unset(tmp_path: Path) -> None:
finally:
understone_server._GAME.store.close() # type: ignore[union-attr]
understone_server._GAME = None
# ---------------------------------------------------------------------------
# WATCH_HTML lockstep guards (the JS twin of texture.py + the v0.6 glow-up)
#
# The inline page reproduces logic that lives in Python; these guard the two
# invariants most prone to silent drift — the texture selection formula and the
# other-player marker — plus the presence of the day-phase machinery.
# ---------------------------------------------------------------------------
def test_watch_html_derives_texture_formula_from_constants() -> None:
"""The page's JS index string is DERIVED from texture._HASH_X / _HASH_Y.
Not a hard-coded "x * 31 + y * 17" snapshot: the expected substring is built
from the live constants, so a Python-side retune that the watch builder
fails to track trips here instead of silently shipping a stale formula.
"""
from understone.screen import texture
expected = f"x * {texture._HASH_X} + y * {texture._HASH_Y}"
assert expected in watch.WATCH_HTML
def test_watch_html_js_selection_agrees_with_textured() -> None:
"""The JS selection arithmetic, replayed in Python, matches ``textured``.
The page computes ``variants[(x * _HASH_X + y * _HASH_Y) % len]``. Replaying
that exact formula here from the SAME constants and the SAME VARIANTS rows
and asserting it equals ``texture.textured`` over a full screen grid proves
both implementations select identically — a stronger lockstep than a string
match, since it pins the result, not the source text.
"""
from understone.screen import texture
for base, choices in texture.VARIANTS.items():
for x in range(24):
for y in range(16):
js_pick = choices[(x * texture._HASH_X + y * texture._HASH_Y) % len(choices)]
assert texture.textured(base, x, y) == js_pick
def test_watch_html_variants_match_texture_table() -> None:
"""Every base->variants row in texture.VARIANTS appears in the JS VARIANTS map.
Glyphs ride into the inline JS as ``\\uXXXX`` escapes, so compare against the
escaped form. A new variant added to Python but not the page trips this.
"""
from understone.screen import texture
html = watch.WATCH_HTML
for base, choices in texture.VARIANTS.items():
for glyph in {base, *choices}:
token = glyph if glyph.isascii() else f"\\u{ord(glyph):04x}"
assert token in html, f"variant glyph {glyph!r} missing from WATCH_HTML"
def test_watch_html_uses_other_player_marker() -> None:
"""Players on the lobby TV wear the ☻ marker (escaped) — no bare '@' marker paint."""
assert "\\u263b" in watch.WATCH_HTML
def test_watch_html_has_day_phase_machinery() -> None:
"""The dusk/dawn glow-up is wired: the tint classes and the UTC-hour read."""
html = watch.WATCH_HTML
assert "applyDayPhase" in html
assert "getUTCHours" in html
assert ".map-frame.night" in html
assert ".map-frame.twilight" in html
assert "Noto Sans Mono" in html
@@ -420,6 +420,85 @@ def test_player_marker_terrain_glyph_rejected(tmp_path: Path) -> None:
load_world(pack)
def test_other_player_marker_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A terrain glyph may not be '' — the v0.6 other-player marker."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = ""
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* reserved for player markers"):
load_world(pack)
def test_ampersand_terrain_glyph_now_accepted(tmp_path: Path) -> None:
"""'&' is no longer an actor marker (☻ took that role), so it is pack-legal.
The load itself is the assertion — it must not raise the actor-marker
rejection. A grass cell then carries the new glyph.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "&"
_rewrite(pack / "terrain.json", mutate)
world = load_world(pack) # no WorldLoadError: '&' is admitted
grass = next(
world.terrain_at(x, y)
for y in range(world.height)
for x in range(world.width)
if world.terrain_at(x, y).key == "grass"
)
assert grass.glyph == "&"
def test_wide_cjk_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A Wide (EAW=W) ideograph would render two columns and tear the frame."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = ""
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* exactly one column"):
load_world(pack)
def test_fullwidth_terrain_glyph_rejected(tmp_path: Path) -> None:
"""A Fullwidth (EAW=F) Latin letter is two columns and is rejected."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["."]["glyph"] = "" # U+FF21 FULLWIDTH LATIN CAPITAL LETTER A
_rewrite(pack / "terrain.json", mutate)
with pytest.raises(WorldLoadError, match=r"terrain\.json.* exactly one column"):
load_world(pack)
def test_reskinned_shipped_pack_glyphs() -> None:
"""The shipped pack carries the v0.6 re-skin and still loads cleanly.
The load-bearing guard for the re-skin: water is ≋ and the three lettered
buildings became ⌂/✚/∩. If a data edit reverts a glyph, this trips.
"""
world = load_world(SHIPPED)
waters = {
world.terrain_at(x, y).glyph
for y in range(world.height)
for x in range(world.width)
if world.terrain_at(x, y).key == "water"
}
assert waters == {""}
by_key = {loc.key: loc.glyph for loc in world.locations}
assert by_key["inn"] == ""
assert by_key["healer"] == ""
assert by_key["dungeon"] == ""
assert by_key["shop"] == "$" # the shop glyph is unchanged
def test_multichar_location_glyph_rejected(tmp_path: Path) -> None:
"""A location glyph must be exactly one character."""
pack = _clone_pack(tmp_path)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.5.0"
__version__ = "0.6.0"
+55 -13
View File
@@ -22,6 +22,7 @@ import shutil
import sys
from typing import TYPE_CHECKING, TextIO
from understone.engine.textwidth import SAFE_PALETTE
from understone.errors import WorldLoadError
from understone.world import PACKAGED_WORLD_DIR, loader
@@ -139,13 +140,15 @@ def _fight_share_pct(world: World) -> int:
def build_authoring_md() -> str:
"""Build the AUTHORING.md manual, bands table included.
"""Build the AUTHORING.md manual, bands table and glyph palette included.
The bands section is generated by iterating the loader's own band tables
(the public constants on :mod:`understone.world.loader`), so the documented
limits are the enforced limits by construction and cannot silently drift.
Both the bands section and the safe-glyph palette are generated from live
source — the loader's own band tables and ``textwidth.SAFE_PALETTE`` — so
the documented limits and the suggested glyphs are exactly what the loader
enforces and admits, and cannot silently drift from it.
"""
return _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands())
md = _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands())
return md.replace("{{PALETTE}}", _render_palette())
def _render_bands() -> str:
@@ -172,10 +175,11 @@ def _render_bands() -> str:
f"`{loader.MAX_NAME_LEN}` printable characters."
)
parts.append(
"* Map glyphs (terrain, location, legend keys): exactly one printable "
"character, and never one of "
"* Map glyphs (terrain, location, legend keys): exactly one terminal "
"column (one printable code point, no fullwidth runes, no combining "
"marks — see the width rule above), and never one of "
+ ", ".join(f"`{g}`" for g in _reserved_glyph_list())
+ " (the frame box-drawing lines and the `@`/`&` player markers)."
+ " (the frame box-drawing lines and the `@`/`` player markers)."
)
parts.append("")
@@ -203,10 +207,25 @@ def _render_bands() -> str:
def _reserved_glyph_list() -> list[str]:
"""Return the reserved glyphs in a stable, readable order for the manual."""
box = [g for g in "┌┐└┘─│═" if g in loader.RESERVED_GLYPHS]
actors = [g for g in "@&" if g in loader.RESERVED_GLYPHS]
actors = [g for g in "@" if g in loader.RESERVED_GLYPHS]
return box + actors
def _render_palette() -> str:
"""Render the safe-glyph appendix straight from ``textwidth.SAFE_PALETTE``.
The glyphs are emitted in their declared order, wrapped in backticks so the
monospace renders them as discrete cells. Generated from the live constant,
so the suggested palette is exactly the set the loader's width gate admits.
"""
glyphs = " ".join(f"`{g}`" for g in SAFE_PALETTE)
return (
"Any single-column glyph the loader accepts is fair game, but these "
"carry the period BBS / CP437 flavour and are all guaranteed safe:\n\n"
f"{glyphs}"
)
_AUTHORING_TEMPLATE = """\
# Authoring a world pack for Understone
@@ -361,6 +380,28 @@ These are generated from the loader's own tables, so they are exactly what
---
## Glyph width — the one-column rule
Every glyph drawn on the map must occupy **exactly one terminal column**. The
frames are box-drawing rectangles; a glyph that renders two columns (a CJK
ideograph like `龍`, an emoji like `🌲`, a fullwidth ``) shoves its row right
and tears the border, and a combining mark (a decomposed `é`, a lone accent)
stacks onto its neighbour and breaks the count the other way. The loader
rejects all of these at load.
What is admitted is judged for the **Western monospace** metrics every
Understone surface actually uses (the Watch's pinned font stack, a chat
client's code block): under those metrics the East-Asian "Ambiguous" width
class renders single-column, and that class is the CP437 heartland — `█`, `♣`,
`↑`, `∩`, `≈`, `★` all live there — so the rule admits it and bars only the
genuinely double-width Wide and Fullwidth classes.
### Safe glyph palette
{{PALETTE}}
---
## Design guidance
**Turn economy.** `daily_turns` is the whole pacing lever: only fighting,
@@ -380,10 +421,11 @@ for variety. The boss should tower over the top random tier — it is the climax
springs, harmless traps, and lore that hints at the endgame. (The validate
report prints your actual fight share so you can tune it.)
**Glyphs.** Map glyphs must be exactly one printable character and must never
collide with the frame's box-drawing lines or the `@`/`&` player markers (see
the bands above). Pick glyphs that read at a glance: `.` open ground, `~`
water, building letters like `I`/`$`/`+`/`>`.
**Glyphs.** Map glyphs must render as exactly one terminal column (see the
one-column rule above) and must never collide with the frame's box-drawing
lines or the `@`/`☻` player markers. Pick glyphs that read at a glance — the
bundled Vale uses `.` open ground, `≋` water, `♣` tree, `⌂` inn, `$` shop, `✚`
healer, `∩` dungeon — and lean on the safe palette for period flavour.
**Boss rules.** Exactly one monster carries `"boss": true` and an `id`, and
`settings.boss_monster` points at it. The boss is the only win condition and is
@@ -0,0 +1,99 @@
"""The one-glyph-one-column contract for everything drawn on the grid.
Every surface Understone paints — the bordered text frames, the golden frames
the screen tests pin, and the Watch's CSS ``1ch``-per-cell map — assumes each
map glyph occupies *exactly one* terminal column. A glyph that renders two
columns (a CJK ideograph, an emoji) shoves the row right and tears the
box-drawing border; a zero-width combining mark stacks onto its neighbour and
desynchronises the column count the other way. :func:`is_grid_safe` is the
single predicate that admits a character to the grid, and :data:`SAFE_PALETTE`
is the curated set of glyphs known to satisfy it with period CP437 flavour.
THE WESTERN-MONOSPACE ASSUMPTION. Width here is judged for the Western
monospace metrics every Understone surface actually uses — the pinned Watch
font stack and the monospace of a chat client's code block. Under those
metrics the East-Asian-Width *Ambiguous* class renders single-column, and
Ambiguous is the CP437 heartland: ``█ ♣ ↑ ∩ ≈ ★`` are all EAW=A. So the rule
bars only the genuinely double-width classes — Wide (``W``) and Fullwidth
(``F``) — and admits Ambiguous, Narrow, Neutral, and Halfwidth. The trade is
deliberate: on a CJK-width terminal an Ambiguous glyph would take two columns,
but Understone's surfaces are not those terminals.
"""
from __future__ import annotations
import unicodedata
# East-Asian-Width classes that render two columns under Western monospace and
# would therefore tear a frame; everything else (Na/N/H/A) renders one column.
_DOUBLE_WIDTH_EAW = frozenset({"W", "F"})
# Unicode general categories that carry no column of their own — combining
# marks (Mn/Mc/Me) stack onto a neighbour, format/control codes (Cf/Cc) are
# invisible — so a single such code point is not a paintable cell.
_ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Mc", "Me", "Cf", "Cc"})
def is_grid_safe(ch: str) -> bool:
"""Return whether *ch* may occupy a single grid cell.
A grid-safe character is exactly one code point, is printable, is not an
East-Asian Wide or Fullwidth glyph (the only classes that render two
columns under the Western monospace metrics our surfaces use — see the
module docstring), and is not a combining mark or format/control code (a
zero-width code point that would desynchronise the column count).
"""
if len(ch) != 1:
return False
if not ch.isprintable():
return False
if unicodedata.east_asian_width(ch) in _DOUBLE_WIDTH_EAW:
return False
return unicodedata.category(ch) not in _ZERO_WIDTH_CATEGORIES
# A curated set of single-column glyphs with BBS / CP437 character, grouped by
# the role an author is likely to want them for. Every entry is grid-safe AND
# free of the loader's reserved markers (two tests assert both), so a pack
# author can pull any of these for terrain, structures, or actors without
# risking a torn frame or colliding with the '@'/'☻' player markers. The black
# smiling face (☻) is the other-player marker and so is NOT here; its white
# twin (☺) is a free being glyph. The grouping is documentation; the set is
# what callers iterate.
SAFE_PALETTE: tuple[str, ...] = (
# terrain
"",
"",
"",
"",
"",
"",
"",
"",
".",
",",
"'",
'"',
"=",
"~",
"§",
"ø",
"¤",
"Ω",
# structures
"",
"",
"",
"",
"",
"$",
"",
"",
# beings
"",
"",
# misc
"",
"",
"",
)
+57 -13
View File
@@ -20,11 +20,13 @@ from understone.engine.log import Event, since_visible
from understone.engine.models import Mode, Monster, Player, Slot
from understone.engine.rank import HallEntry, RankEntry, leaderboard
from understone.engine.rng import GameRNG
from understone.engine.textwidth import is_grid_safe
from understone.persistence import EVENT_TAIL_KEEP
from understone.screen.grid import Cell, CellGrid
from understone.screen.menus import render_menu
from understone.screen.palette import Color
from understone.screen.text_renderer import render_frame
from understone.screen.texture import textured
from understone.screen.viewport import compute_window
if TYPE_CHECKING:
@@ -118,6 +120,17 @@ _HERALD_TEMPLATES: dict[str, tuple[str, ...]] = {
}
def _is_narrow_text(text: str) -> bool:
"""Return whether every code point in *text* fits a single ledger column.
True only when each character is grid-safe — one printable column, no
fullwidth rune, no combining mark. Spaces qualify (they are narrow), so
multi-word reasons and mail pass; a CJK ideograph, an emoji, a fullwidth
letter, or a decomposed accent does not.
"""
return all(is_grid_safe(ch) for ch in text)
class Game:
"""Stateful coordinator over a single shared world."""
@@ -169,15 +182,22 @@ class Game:
def _sanitize(text: str, max_len: int) -> str | None:
"""Return *text* stripped, or ``None`` if it fails free-text hygiene.
Rejects empty input, anything longer than *max_len*, and any string
Rejects empty input, anything longer than *max_len*, any string
carrying a non-printable or control character (``\\n``, ``\\r``,
``\\t`` included — ``str.isprintable`` treats them all as unprintable).
``\\t`` included — ``str.isprintable`` treats them all as unprintable),
and any string carrying a glyph that will not fit the narrow ledger:
a fullwidth rune or a combining mark (the same one-column contract the
map glyphs obey, via :func:`~understone.engine.textwidth.is_grid_safe`).
Player names, bestow reasons, and inn mail all render inside fixed-width
frames and tables, so a wide rune would shove a column out of true.
This is the single chokepoint for player-authored free text reaching
the durable store and the public log.
"""
cleaned = text.strip()
if not cleaned or len(cleaned) > max_len or not cleaned.isprintable():
return None
if not _is_narrow_text(cleaned):
return None
return cleaned
def _footer(self, player: Player) -> str:
@@ -263,9 +283,11 @@ class Game:
color = _COLOR_BY_NAME.get(terrain.color, Color.DEFAULT)
loc = self.world.location_at(x, y)
if loc is not None:
# Location glyphs are landmarks; never texture them.
loc_color = _COLOR_BY_NAME.get(loc.color, Color.TOWN)
return Cell(loc.glyph, loc_color)
return Cell(terrain.glyph, color)
# Terrain glyphs get a deterministic, position-keyed variant for texture.
return Cell(textured(terrain.glyph, x, y), color)
def _paint_viewport(self, player: Player) -> CellGrid:
x0, y0 = compute_window(
@@ -281,7 +303,7 @@ class Game:
for other in self.players.values():
if other.name == player.name or other.mode is not Mode.TILE:
continue
self._mark(grid, x0, y0, other.x, other.y, Cell("&", Color.OTHER_PLAYER))
self._mark(grid, x0, y0, other.x, other.y, Cell("", Color.OTHER_PLAYER))
self._mark(grid, x0, y0, player.x, player.y, Cell("@", Color.PLAYER))
return grid
@@ -320,19 +342,27 @@ class Game:
# -- tool: join ------------------------------------------------------
def join(self, name: str) -> str:
"""Create a new adventurer, or resume an existing one by name."""
"""Create a new adventurer, or resume an existing one by name.
Resume is identity-preserving and runs FIRST: an exact stripped-name
match against a stored adventurer is welcomed back without re-running
the name hygiene gate, so a character whose name predates a since-
tightened rule (e.g. a wide rune now barred at creation) is never locked
out of their own save. The sanitizer therefore governs CREATION only —
a NEW name must still pass it.
"""
existing = self.players.get(name.strip())
if existing is not None:
return self._resume(existing)
clean = self._sanitize(name, _NAME_MAX_LEN)
if clean is None:
if len(name.strip()) > _NAME_MAX_LEN:
stripped = name.strip()
if len(stripped) > _NAME_MAX_LEN:
return "The ledger is narrow — choose a name of 24 letters or fewer."
if stripped.isprintable() and not _is_narrow_text(stripped):
return "The ledger's columns are narrow — wide runes will not fit."
return "The gatekeeper squints at those strange runes. Plain letters, traveller."
existing = self._get(clean)
if existing is not None:
self._ensure_day(existing)
self.store.upsert_player(existing)
self.store.commit()
banner = f"Welcome back to {self.world.name}, {existing.name}."
return self._overworld_frame(existing, lines=self._with_watch(banner))
settings = self.world.settings
atk, def_, max_hp = self._fresh_combat_stats()
@@ -374,6 +404,20 @@ class Game:
)
return self._overworld_frame(player, lines=self._with_watch(banner))
def _resume(self, existing: Player) -> str:
"""Welcome a stored adventurer back, rolling their day and persisting.
The resume path for ``join``: it never re-validates the name (an exact
stored identity is admitted as-is, however old the rule it predates),
rolls the daily clock, commits, and renders the overworld frame with the
'welcome back' banner and the watch line.
"""
self._ensure_day(existing)
self.store.upsert_player(existing)
self.store.commit()
banner = f"Welcome back to {self.world.name}, {existing.name}."
return self._overworld_frame(existing, lines=self._with_watch(banner))
def _with_watch(self, banner: str) -> list[str]:
"""Return the join banner as frame lines, plus the watch line if set."""
line = self.watch_line()
@@ -0,0 +1,54 @@
"""Deterministic terrain texturing — vary a terrain glyph by map position.
A field of identical `.`s reads flat; swapping in an occasional `,` or `'`
gives the overworld a hand-stippled BBS texture without storing anything on the
map. The variation is a PURE FUNCTION OF THE CELL COORDINATE, so it is stable
across redraws (a cell always picks the same variant) and reproducible the
model never sees it, only the renderer.
Only *terrain* cells are textured. The player marker, the other-player marker,
and location glyphs are painted on top and are never varied, so the eye can
always find them.
LOCKSTEP CONTRACT. The Watch page (``understone.watch.WATCH_HTML``) paints its
own base map in JavaScript and reproduces the EXACT same selection the same
``VARIANTS`` rows and the same ``(x * _HASH_X + y * _HASH_Y) % n`` index. The
page builds that index string FROM the :data:`_HASH_X` / :data:`_HASH_Y`
constants here (``understone.watch`` imports them), so a retune of either
number moves the JS with it; only the ``VARIANTS`` table must still be mirrored
by hand, or the live map and the tool frames will drift apart.
"""
from __future__ import annotations
# The position hash multipliers. The variant index for a cell is
# ``(x * _HASH_X + y * _HASH_Y) % len`` — two odd, coprime constants chosen so
# neighbouring cells spread across the variant row rather than banding. The
# Watch JS builds its own copy of this formula FROM these same two numbers
# (``understone.watch`` imports them), so a retune here moves the page in
# lockstep; a guard test pins the agreement.
_HASH_X = 31
_HASH_Y = 17
# Base glyph -> the ordered string of glyphs it may render as. The base glyph
# is index 0, so a cell that hashes to 0 is unchanged. Glyphs not listed here
# are never varied. Keep in lockstep with the Watch JS VARIANTS map.
VARIANTS: dict[str, str] = {
".": ".,'",
"": "≋≈",
}
def textured(glyph: str, x: int, y: int) -> str:
"""Return the variant of *glyph* for cell ``(x, y)``, or *glyph* unchanged.
When *glyph* has a :data:`VARIANTS` row, the cell coordinate selects one of
its variants by ``(x * _HASH_X + y * _HASH_Y) % len`` a fixed,
position-only hash so the choice is stable per cell and identical to the
Watch's. Glyphs with no row (every actor and location glyph, and any
un-listed terrain) are returned as-is.
"""
choices = VARIANTS.get(glyph)
if choices is None:
return glyph
return choices[(x * _HASH_X + y * _HASH_Y) % len(choices)]
+3 -3
View File
@@ -79,8 +79,8 @@ THE GOLDEN RULE
THE TWO MODES OF PLAY
1. The overworld (TILE mode). Tools return an ASCII "keyframe": a bordered
map window centred on the player. '@' is the player, '&' is another
adventurer, letters are buildings (I inn, $ shop, + healer, > dungeon).
map window centred on the player. '@' is the player, '' is another
adventurer, glyphs are buildings ( inn, $ shop, healer, dungeon).
Movement here is FREE it costs no daily turns.
2. Location interiors (MENU mode). Stepping onto a building opens a menu of
options like (R)est, (B)uy, (H)eal, (D)escend, (L)eave.
@@ -286,7 +286,7 @@ def door_look(player: str) -> str:
"""Redraw what the adventurer currently sees (read-only).
On the overworld this is an ASCII map keyframe centred on the player
('@' is you, '&' are other players, letters are buildings). Inside a
('@' is you, '' are other players, glyphs are buildings). Inside a
building it is that location's menu. Present the result verbatim in a
fenced code block, then narrate.
+81 -4
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from understone.engine.models import Mode
from understone.screen import texture
if TYPE_CHECKING:
from understone.engine.log import Event
@@ -131,7 +132,10 @@ def _recent_events(game: Game) -> list[Event]:
# createElement / textContent). The base map is painted once from world.json;
# players are an absolutely-positioned overlay repainted from state.json every
# two seconds. On a fetch failure the page dims and shows "SIGNAL LOST".
WATCH_HTML = """\
#
# ``__HASH_EXPR__`` is filled below from the texture-module hash constants, so
# the JS index formula tracks a Python-side retune (see _build_watch_html).
_WATCH_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
@@ -152,7 +156,7 @@ WATCH_HTML = """\
margin: 0;
background: var(--bg);
color: var(--phosphor);
font-family: "DejaVu Sans Mono", "Liberation Mono", "Courier New", monospace;
font-family: "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "Courier New", monospace;
font-size: 14px;
line-height: 1.2;
}
@@ -221,7 +225,23 @@ WATCH_HTML = """\
overflow: auto;
max-width: 100%;
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.6);
transition: filter 1.2s ease;
}
/* Time-of-day wash, toggled from the UTC hour of the state payload. The
overlay is non-interactive and sits above the map but below the scanlines.
night: a subtle blue dim; dawn/dusk: a faint amber wash; day: nothing. */
.map-frame::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 1.2s ease, background-color 1.2s ease;
z-index: 5;
}
.map-frame.night { filter: brightness(0.78) saturate(0.85); }
.map-frame.night::after { opacity: 1; background-color: rgba(74, 120, 200, 0.16); }
.map-frame.twilight::after { opacity: 1; background-color: rgba(255, 180, 77, 0.12); }
#map {
position: relative;
white-space: pre;
@@ -318,6 +338,22 @@ WATCH_HTML = """\
return PALETTE[name] || PALETTE.default;
}
// Deterministic terrain texture. MUST stay in lockstep with
// understone.screen.texture: the same base->variants rows and the same
// index formula. The formula below is INTERPOLATED from texture._HASH_X /
// _HASH_Y at module build time, so a Python-side retune rewrites this line;
// only the VARIANTS rows must still be mirrored by hand.
var VARIANTS = {
".": ".,'",
"\\u224b": "\\u224b\\u2248"
};
function textured(ch, x, y) {
var choices = VARIANTS[ch];
if (!choices) { return ch; }
return choices.charAt((__HASH_EXPR__) % choices.length);
}
var overlay = document.getElementById("overlay");
var mapEl = document.getElementById("map");
var liveLabel = document.getElementById("live-label");
@@ -351,6 +387,8 @@ WATCH_HTML = """\
var runColor = null;
for (var x = 0; x < row.length; x++) {
var ch = row.charAt(x);
// Colour keys off the BASE terrain glyph; the rendered glyph is the
// position-keyed variant (a variant shares its terrain's colour).
var col = colorFor(legend[ch]);
if (runColor === null) { runColor = col; }
if (col !== runColor) {
@@ -358,7 +396,7 @@ WATCH_HTML = """\
runText = "";
runColor = col;
}
runText += ch;
runText += textured(ch, x, y);
}
if (runText.length) { rowEl.appendChild(makeSpan(runText, runColor)); }
mapEl.insertBefore(rowEl, overlay);
@@ -404,7 +442,10 @@ WATCH_HTML = """\
el.className = "pc";
el.style.left = "calc(" + p.x + " * 1ch)";
el.style.top = "calc(" + p.y + " * 1lh)";
el.textContent = "@";
// Every adventurer on the lobby TV is "another player" (there is no
// viewer here), so all wear the other-player marker. Mirrors the ''
// the game frame paints for rivals.
el.textContent = "\\u263b";
el.title = p.name;
pcLayer.appendChild(el);
}
@@ -506,6 +547,26 @@ WATCH_HTML = """\
}
}
var mapFrame = document.querySelector(".map-frame");
// Tint the map by the UTC hour of the world clock. The bands:
// night 20:00-05:59 -> subtle dim + blue ('night' class)
// dawn 06:00-07:59 -> faint amber wash ('twilight' class)
// dusk 18:00-19:59 -> faint amber wash ('twilight' class)
// day 08:00-17:59 -> no tint
// UTC (not local) so every spectator sees the same sky as the game clock.
function applyDayPhase(iso) {
var d = new Date(iso);
mapFrame.classList.remove("night", "twilight");
if (isNaN(d.getTime())) { return; }
var h = d.getUTCHours();
if (h >= 20 || h < 6) {
mapFrame.classList.add("night");
} else if (h < 8 || h >= 18) {
mapFrame.classList.add("twilight");
}
}
function getJSON(url) {
return fetch(url, { cache: "no-store" }).then(function (r) {
if (!r.ok) { throw new Error("HTTP " + r.status); }
@@ -519,6 +580,7 @@ WATCH_HTML = """\
renderAdventurers(state.players || []);
renderHall(state.hall || []);
renderHerald(state.herald || []);
applyDayPhase(state.ts);
setLive(true, state.ts);
}).catch(function () {
setLive(false, null);
@@ -546,3 +608,18 @@ WATCH_HTML = """\
</body>
</html>
"""
def _build_watch_html() -> str:
"""Fill the texture hash formula into the page template.
The JS ``textured`` index is interpolated from
:data:`~understone.screen.texture._HASH_X` / ``_HASH_Y`` so the page's
formula is a derivation of the same two constants the Python renderer uses;
a retune of either moves both, and a guard test pins the agreement.
"""
hash_expr = f"x * {texture._HASH_X} + y * {texture._HASH_Y}"
return _WATCH_HTML_TEMPLATE.replace("__HASH_EXPR__", hash_expr)
WATCH_HTML = _build_watch_html()
@@ -2,7 +2,7 @@
"inn": {
"kind": "inn",
"name": "The Sleeping Drake",
"glyph": "I",
"glyph": "",
"color": "town",
"actions": ["rest", "gamble", "leave"],
"flavor": [
@@ -28,7 +28,7 @@
"healer": {
"kind": "healer",
"name": "The Quiet Shrine",
"glyph": "+",
"glyph": "",
"color": "town",
"actions": ["heal", "leave"],
"flavor": [
@@ -39,7 +39,7 @@
"dungeon": {
"kind": "dungeon",
"name": "The Understone Deep",
"glyph": ">",
"glyph": "",
"color": "dungeon",
"actions": ["descend", "challenge", "leave"],
"flavor": [
@@ -15,7 +15,7 @@
},
"~": {
"key": "water",
"glyph": "~",
"glyph": "",
"walkable": false,
"encounter_rate": 0.0,
"color": "water"
+14 -9
View File
@@ -30,6 +30,7 @@ from understone.engine.models import (
WorldEvent,
Zone,
)
from understone.engine.textwidth import is_grid_safe
from understone.engine.world import World
from understone.errors import WorldLoadError
@@ -84,32 +85,36 @@ MAX_NAME_LEN = 48
# Box-drawing glyphs the frame and Herald renderers own; a map glyph must never
# be one of these (it would tear the borders) — the double bar is the Herald
# rule, the rest are the map/menu frame. '@' and '&' are the player and
# rule, the rest are the map/menu frame. '@' and '' are the player and
# other-player markers, so a map glyph must not impersonate an actor either.
_BOX_DRAWING_GLYPHS = frozenset("┌┐└┘─│═")
_ACTOR_GLYPHS = frozenset("@&")
_ACTOR_GLYPHS = frozenset("@")
RESERVED_GLYPHS = _BOX_DRAWING_GLYPHS | _ACTOR_GLYPHS
def _check_glyph(glyph: str, where: str, *, role: str = "glyph") -> None:
"""Validate a single map glyph (terrain, location, or legend key).
A glyph must be exactly one printable character that is neither a frame
box-drawing line nor a player marker, so it cannot tear the rendered
border or masquerade as an adventurer. *role* names the field for the
author-facing message.
A glyph must render as exactly one terminal column (the grid contract in
:mod:`understone.engine.textwidth`: one printable code point, no fullwidth
runes, no combining marks) and must be neither a frame box-drawing line nor
a player marker, so it cannot tear the rendered border or masquerade as an
adventurer. *role* names the field for the author-facing message.
"""
if len(glyph) != 1:
raise WorldLoadError(f"{where} {role} must be a single character, got {glyph!r}")
if not glyph.isprintable():
raise WorldLoadError(f"{where} {role} {glyph!r} must be printable")
if not is_grid_safe(glyph):
raise WorldLoadError(
f"{where} {role} {glyph!r} must render exactly one column "
"(no emoji, no fullwidth, no combining marks)"
)
if glyph in _BOX_DRAWING_GLYPHS:
raise WorldLoadError(
f"{where} {role} {glyph!r} is a box-drawing character reserved for frame borders"
)
if glyph in _ACTOR_GLYPHS:
raise WorldLoadError(
f"{where} {role} {glyph!r} is reserved for player markers ('@' you, '&' others)"
f"{where} {role} {glyph!r} is reserved for player markers ('@' you, '' others)"
)