mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -6,4 +6,4 @@ import understone
|
||||
|
||||
|
||||
def test_version_present() -> None:
|
||||
assert understone.__version__ == "0.5.0"
|
||||
assert understone.__version__ == "0.6.0"
|
||||
|
||||
@@ -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",
|
||||
"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
|
||||
@@ -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"] = "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)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Understone — a BBS-style ANSI door game served over MCP."""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.6.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"•",
|
||||
"⁂",
|
||||
"★",
|
||||
)
|
||||
@@ -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)]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user