diff --git a/examples/door-game/README.md b/examples/door-game/README.md index 8a761411..be16c5be 100644 --- a/examples/door-game/README.md +++ b/examples/door-game/README.md @@ -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. diff --git a/examples/door-game/pyproject.toml b/examples/door-game/pyproject.toml index 1489c02d..4dd1b515 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.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" diff --git a/examples/door-game/tests/test_cli.py b/examples/door-game/tests/test_cli.py index caf61a9f..6be9353e 100644 --- a/examples/door-game/tests/test_cli.py +++ b/examples/door-game/tests/test_cli.py @@ -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() diff --git a/examples/door-game/tests/test_game.py b/examples/door-game/tests/test_game.py index 23ea18d1..d3b31813 100644 --- a/examples/door-game/tests/test_game.py +++ b/examples/door-game/tests/test_game.py @@ -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 (A) is two columns and refused.""" + game = _game(tmp_path, clock) + out = game.join("A") + 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) diff --git a/examples/door-game/tests/test_mcp_integration.py b/examples/door-game/tests/test_mcp_integration.py index 8eb7e9a9..09b245d4 100644 --- a/examples/door-game/tests/test_mcp_integration.py +++ b/examples/door-game/tests/test_mcp_integration.py @@ -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"] diff --git a/examples/door-game/tests/test_package.py b/examples/door-game/tests/test_package.py index a6f9ec82..84845998 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.5.0" + assert understone.__version__ == "0.6.0" diff --git a/examples/door-game/tests/test_texture.py b/examples/door-game/tests/test_texture.py new file mode 100644 index 00000000..e9ee28ea --- /dev/null +++ b/examples/door-game/tests/test_texture.py @@ -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 diff --git a/examples/door-game/tests/test_textwidth.py b/examples/door-game/tests/test_textwidth.py new file mode 100644 index 00000000..abfa903c --- /dev/null +++ b/examples/door-game/tests/test_textwidth.py @@ -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", + "A": "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 diff --git a/examples/door-game/tests/test_watch.py b/examples/door-game/tests/test_watch.py index 6aa9ba31..1aa610bd 100644 --- a/examples/door-game/tests/test_watch.py +++ b/examples/door-game/tests/test_watch.py @@ -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 diff --git a/examples/door-game/tests/test_world_loader.py b/examples/door-game/tests/test_world_loader.py index 388c3fb7..0a63e407 100644 --- a/examples/door-game/tests/test_world_loader.py +++ b/examples/door-game/tests/test_world_loader.py @@ -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"] = "A" # 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) diff --git a/examples/door-game/understone/__init__.py b/examples/door-game/understone/__init__.py index e435c75d..f2ab94d4 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.5.0" +__version__ = "0.6.0" diff --git a/examples/door-game/understone/cli.py b/examples/door-game/understone/cli.py index 56505e8e..c60067ca 100644 --- a/examples/door-game/understone/cli.py +++ b/examples/door-game/understone/cli.py @@ -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 `A`) 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 diff --git a/examples/door-game/understone/engine/textwidth.py b/examples/door-game/understone/engine/textwidth.py new file mode 100644 index 00000000..71afcb31 --- /dev/null +++ b/examples/door-game/understone/engine/textwidth.py @@ -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 + "•", + "⁂", + "★", +) diff --git a/examples/door-game/understone/game.py b/examples/door-game/understone/game.py index fb1baa6c..bb3029cf 100644 --- a/examples/door-game/understone/game.py +++ b/examples/door-game/understone/game.py @@ -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() diff --git a/examples/door-game/understone/screen/texture.py b/examples/door-game/understone/screen/texture.py new file mode 100644 index 00000000..ec8c8e4c --- /dev/null +++ b/examples/door-game/understone/screen/texture.py @@ -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)] diff --git a/examples/door-game/understone/server.py b/examples/door-game/understone/server.py index 01cad10d..59fb12b1 100644 --- a/examples/door-game/understone/server.py +++ b/examples/door-game/understone/server.py @@ -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. diff --git a/examples/door-game/understone/watch.py b/examples/door-game/understone/watch.py index 7ae25d47..a6b50be2 100644 --- a/examples/door-game/understone/watch.py +++ b/examples/door-game/understone/watch.py @@ -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 = """\ @@ -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 = """\ """ + + +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() diff --git a/examples/door-game/understone/world/data/locations.json b/examples/door-game/understone/world/data/locations.json index fb930c19..4affbbd8 100644 --- a/examples/door-game/understone/world/data/locations.json +++ b/examples/door-game/understone/world/data/locations.json @@ -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": [ diff --git a/examples/door-game/understone/world/data/terrain.json b/examples/door-game/understone/world/data/terrain.json index b96fa3b3..6fb0d25d 100644 --- a/examples/door-game/understone/world/data/terrain.json +++ b/examples/door-game/understone/world/data/terrain.json @@ -15,7 +15,7 @@ }, "~": { "key": "water", - "glyph": "~", + "glyph": "≋", "walkable": false, "encounter_rate": 0.0, "color": "water" diff --git a/examples/door-game/understone/world/loader.py b/examples/door-game/understone/world/loader.py index befaab06..5bb5756a 100644 --- a/examples/door-game/understone/world/loader.py +++ b/examples/door-game/understone/world/loader.py @@ -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)" )