From d40c4c85ee3eb4542f9c9e845db7a08e8c2fbdf8 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 12 Jun 2026 18:10:08 -0700 Subject: [PATCH] =?UTF-8?q?feat(examples):=20Understone=20v0.4=20=E2=80=94?= =?UTF-8?q?=20the=20authoring=20pipeline=20(worlds=20as=20data)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IGM seam realized: world packs are now a first-class authoring target for models and humans, with a validate loop and a loader hardened for routinely-untrusted generated content. - understone newpack DIR scaffolds a pack (the six content JSONs templated from the shipped Vale) plus AUTHORING.md — a manual written for a model to follow cold. Its bands table is RENDERED FROM the loader's own band constants at scaffold time, so documented limits and enforced limits cannot drift. - understone validate DIR loads a pack and prints either a pack report ("This pack is sound. The door stands open.") or the loader's file/index/field-naming error — the authoring feedback loop. - Loader hardening: glyphs must be one printable column-safe character and never the frame box-drawing set or the @/& player markers (map content cannot impersonate players or forge frame chrome); map dims 8..256; per-file count caps; display-name length caps. All errors instructive. - The packaged-world path is single-sourced (understone.world. PACKAGED_WORLD_DIR) for the server default and the scaffold template. - README "Authoring worlds" section frames the loop: newpack -> write or generate -> validate -> serve with UNDERSTONE_WORLD=dir. Review round: bug finder returned zero findings; quality round fixed the world.json doc example (it showed a zone fragment where an authoring model would copy a whole-file shape — now a labeled skeleton), the stale Usage docstring, and the duplicated packaged-path constant. Tests 166 -> 184. Scaffold round-trips through load_world by test. --- examples/door-game/README.md | 34 ++ examples/door-game/pyproject.toml | 2 +- examples/door-game/tests/test_cli.py | 230 ++++++++++ .../door-game/tests/test_mcp_integration.py | 2 +- examples/door-game/tests/test_package.py | 2 +- examples/door-game/tests/test_world_loader.py | 83 ++++ examples/door-game/understone/__init__.py | 2 +- examples/door-game/understone/cli.py | 411 ++++++++++++++++++ examples/door-game/understone/server.py | 56 ++- .../door-game/understone/world/__init__.py | 7 + examples/door-game/understone/world/loader.py | 112 ++++- 11 files changed, 912 insertions(+), 29 deletions(-) create mode 100644 examples/door-game/tests/test_cli.py create mode 100644 examples/door-game/understone/cli.py diff --git a/examples/door-game/README.md b/examples/door-game/README.md index 3808df09..631ff57d 100644 --- a/examples/door-game/README.md +++ b/examples/door-game/README.md @@ -131,6 +131,40 @@ If you bind to `0.0.0.0` to share the world across a network, advertise a host that browsers can actually reach (your machine's LAN address or hostname) rather than `0.0.0.0` itself — the link is composed from `UNDERSTONE_HOST`. +## Authoring worlds + +The Vale of Understone is just the *bundled* world. The whole game — its map, +monsters, economy, and endgame — is a **content pack**: a directory of six JSON +files the server loads at start. Nothing about the Vale is privileged; point +the server at another pack and it runs that world instead. This is the seam +where the game becomes its own authoring target: a pack is plain data, so a +person *or an LLM* can write one, and the same zero-setup philosophy that makes +the game playable with no prompt makes it **authorable with no code**. + +The loop has three commands: + +```bash +understone newpack mypack # scaffold a pack (copies the Vale as a template) +# ...edit or LLM-generate the JSON in mypack/ to describe your world... +understone validate mypack # check it; prints a report or names what's wrong +UNDERSTONE_WORLD=mypack understone # serve your world +``` + +`newpack` writes a starting template plus an `AUTHORING.md` manual — the +file-by-file schema, the enforced limits, and design guidance — written to be +followed cold by a model. `validate` loads the pack through exactly the same +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 +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. +Because packs are now routinely untrusted, generated output, those error +messages are not a nuisance — they are the **feedback loop**. Iterate against +them until the door stands open. + ## Registering with Turnstone Understone is an ordinary MCP server, so it plugs into Turnstone's MCP client diff --git a/examples/door-game/pyproject.toml b/examples/door-game/pyproject.toml index 79621368..19cbcb9d 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.3.0" +version = "0.4.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 new file mode 100644 index 00000000..caf61a9f --- /dev/null +++ b/examples/door-game/tests/test_cli.py @@ -0,0 +1,230 @@ +"""Tests for the pack-authoring command surface. + +Covers the validate/newpack functions directly (sound and broken packs, the +scaffold round-trip, AUTHORING.md generation from the live loader bands, and +the refuse-non-empty guard), the ``server.main`` argv dispatch (validate routes +through and bare invocation still reaches serve without binding a port), and +one end-to-end subprocess smoke of ``python -m understone validate``. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from io import StringIO +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +from understone import cli, server +from understone.world import loader + +if TYPE_CHECKING: + from collections.abc import Callable + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +SHIPPED = EXAMPLE_DIR / "understone" / "world" / "data" + +# The six content files a scaffolded pack must carry, plus the manual. +_PACK_JSONS = { + "terrain.json", + "monsters.json", + "items.json", + "locations.json", + "events.json", + "world.json", +} + + +# --------------------------------------------------------------------------- +# cli_validate +# --------------------------------------------------------------------------- + + +def test_cli_validate_sound_pack_reports_and_returns_zero() -> None: + out, err = StringIO(), StringIO() + rc = cli.cli_validate(SHIPPED, out=out, err=err) + + assert rc == 0 + report = out.getvalue() + assert "This pack is sound. The door stands open." in report + # The report surfaces the headline facts the brief calls for. + assert "The Vale of Understone" in report + assert "96x48" in report + assert "1 boss" in report + assert "% fight" in report + assert err.getvalue() == "" + + +def test_cli_validate_broken_pack_names_field_and_returns_two(tmp_path: Path) -> None: + # A pack whose daily_turns is out of band: the loader names the field. + pack = _clone_shipped(tmp_path) + _patch_world(pack, _break_daily_turns) + + out, err = StringIO(), StringIO() + rc = cli.cli_validate(pack, out=out, err=err) + + assert rc == 2 + message = err.getvalue() + assert message.startswith("The pack is flawed:") + assert "daily_turns" in message # the offending field is named + assert out.getvalue() == "" + + +def test_cli_validate_missing_directory_returns_two(tmp_path: Path) -> None: + out, err = StringIO(), StringIO() + rc = cli.cli_validate(tmp_path / "nope", out=out, err=err) + assert rc == 2 + assert "The pack is flawed:" in err.getvalue() + + +# --------------------------------------------------------------------------- +# cli_newpack +# --------------------------------------------------------------------------- + + +def test_cli_newpack_writes_template_and_manual(tmp_path: Path) -> None: + dest = tmp_path / "mypack" + out, err = StringIO(), StringIO() + rc = cli.cli_newpack(dest, out=out, err=err) + + assert rc == 0 + present = {p.name for p in dest.iterdir()} + assert present >= _PACK_JSONS # the six content files are all there + assert "AUTHORING.md" in present + # Next-steps guidance points the author at the validate verb. + assert "understone validate" in out.getvalue() + + +def test_cli_newpack_scaffold_validates(tmp_path: Path) -> None: + """The load-bearing test: a freshly scaffolded pack loads cleanly. + + newpack -> load_world round-trip. If the template the scaffolder copies + ever drifts out of the loader's bands, this fails immediately. + """ + dest = tmp_path / "mypack" + assert cli.cli_newpack(dest, out=StringIO(), err=StringIO()) == 0 + + world = loader.load_world(dest) + assert world.name == "The Vale of Understone" + assert world.width == 96 + + +def test_cli_newpack_authoring_md_renders_live_band(tmp_path: Path) -> None: + """AUTHORING.md's bands are generated from the loader, not hand-copied. + + The daily_turns band is read straight from the live loader table and must + appear verbatim in the scaffolded manual — proving generation from source. + """ + dest = tmp_path / "mypack" + cli.cli_newpack(dest, out=StringIO(), err=StringIO()) + + manual = (dest / "AUTHORING.md").read_text(encoding="utf-8") + lo, hi = loader.SETTINGS_BANDS["daily_turns"] + assert lo is not None and hi is not None + assert f"`{lo}..{hi}`" in manual + assert "daily_turns" in manual + + +def test_cli_newpack_refuses_non_empty_dir(tmp_path: Path) -> None: + dest = tmp_path / "occupied" + dest.mkdir() + (dest / "keep.txt").write_text("mine", encoding="utf-8") + + out, err = StringIO(), StringIO() + rc = cli.cli_newpack(dest, out=out, err=err) + + assert rc == 2 + assert "non-empty" in err.getvalue() + # The pre-existing file is untouched (nothing was scaffolded over it). + assert (dest / "keep.txt").read_text(encoding="utf-8") == "mine" + assert not (dest / "AUTHORING.md").exists() + + +def test_cli_newpack_into_empty_existing_dir_succeeds(tmp_path: Path) -> None: + """An existing but empty directory is a fine scaffold target.""" + dest = tmp_path / "empty" + dest.mkdir() + assert cli.cli_newpack(dest, out=StringIO(), err=StringIO()) == 0 + assert (dest / "AUTHORING.md").exists() + + +# --------------------------------------------------------------------------- +# server.main argv dispatch +# --------------------------------------------------------------------------- + + +def test_main_validate_dispatch_returns_status( + tmp_path: Path, capsys: pytest.CaptureFixture +) -> None: + # A broken pack routed through main exits 2; a sound one exits 0. + pack = _clone_shipped(tmp_path) + _patch_world(pack, _break_daily_turns) + + with pytest.raises(SystemExit) as broken: + server.main(["validate", str(pack)]) + assert broken.value.code == 2 + + with pytest.raises(SystemExit) as sound: + server.main(["validate", str(SHIPPED)]) + assert sound.value.code == 0 + assert "The door stands open." in capsys.readouterr().out + + +def test_main_newpack_dispatch(tmp_path: Path) -> None: + dest = tmp_path / "viamain" + with pytest.raises(SystemExit) as exc: + server.main(["newpack", str(dest)]) + assert exc.value.code == 0 + assert (dest / "AUTHORING.md").exists() + + +def test_bare_invocation_resolves_to_serve_without_side_effects() -> None: + """Parsing no argv yields the serve path, and parsing has no side effects. + + The transport launch (_serve) is reachable, but argument parsing neither + loads a world nor binds a port — so this asserts the resolved command + without ever calling _serve. + """ + args = server._build_parser().parse_args([]) + assert args.cmd is None # None => the serve branch in main() + assert callable(server._serve) + + +def test_subprocess_validate_packaged_world_exits_zero() -> None: + """End-to-end smoke: `python -m understone validate ` exits 0.""" + result = subprocess.run( + [sys.executable, "-m", "understone", "validate", str(SHIPPED)], + cwd=EXAMPLE_DIR, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert "The door stands open." in result.stdout + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _clone_shipped(tmp_path: Path) -> Path: + dest = tmp_path / "pack" + shutil.copytree(SHIPPED, dest) + return dest + + +def _patch_world(pack: Path, mutate: Callable[[dict[str, Any]], None]) -> None: + path = pack / "world.json" + data = json.loads(path.read_text(encoding="utf-8")) + mutate(data) + path.write_text(json.dumps(data), encoding="utf-8") + + +def _break_daily_turns(data: dict[str, Any]) -> None: + """Set daily_turns out of its 1..100 band so the pack fails to load.""" + data["settings"]["daily_turns"] = 0 diff --git a/examples/door-game/tests/test_mcp_integration.py b/examples/door-game/tests/test_mcp_integration.py index 35a71252..8eb7e9a9 100644 --- a/examples/door-game/tests/test_mcp_integration.py +++ b/examples/door-game/tests/test_mcp_integration.py @@ -32,7 +32,7 @@ from understone import server as understone_server if TYPE_CHECKING: from pathlib import Path -PACK = str(understone_server._PACKAGED_WORLD) +PACK = str(understone_server.PACKAGED_WORLD_DIR) def _find_free_port() -> int: diff --git a/examples/door-game/tests/test_package.py b/examples/door-game/tests/test_package.py index c1307685..f90324ab 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.3.0" + assert understone.__version__ == "0.4.0" diff --git a/examples/door-game/tests/test_world_loader.py b/examples/door-game/tests/test_world_loader.py index c75b325f..59282dd2 100644 --- a/examples/door-game/tests/test_world_loader.py +++ b/examples/door-game/tests/test_world_loader.py @@ -322,3 +322,86 @@ def test_wyrm_min_level_out_of_band_rejected(tmp_path: Path) -> None: _rewrite(pack / "world.json", mutate) with pytest.raises(WorldLoadError, match="wyrm_min_level"): load_world(pack) + + +# --------------------------------------------------------------------------- +# v0.4 loader hardening: glyphs, map size, count caps, and name lengths +# +# Packs are now routinely untrusted LLM output, so the loader bands the shapes +# that could tear a frame, balloon memory, or impersonate a player. Each +# rejection still names the file and field at fault. +# --------------------------------------------------------------------------- + + +def test_box_drawing_terrain_glyph_rejected(tmp_path: Path) -> None: + """A terrain glyph may not be a frame box-drawing line (it would tear borders).""" + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + data["."]["glyph"] = "─" # the horizontal frame run + + _rewrite(pack / "terrain.json", mutate) + with pytest.raises(WorldLoadError, match=r"terrain\.json.* box-drawing"): + load_world(pack) + + +def test_player_marker_terrain_glyph_rejected(tmp_path: Path) -> None: + """A terrain glyph may not be '@' — that is the player's own 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_multichar_location_glyph_rejected(tmp_path: Path) -> None: + """A location glyph must be exactly one character.""" + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + data["inn"]["glyph"] = "In" # two characters + + _rewrite(pack / "locations.json", mutate) + with pytest.raises(WorldLoadError, match=r"locations\.json.* single character"): + load_world(pack) + + +def test_oversized_map_rejected(tmp_path: Path) -> None: + """A 300x300 map is past the dimension ceiling (8..256).""" + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + data["width"] = 300 + data["height"] = 300 + + _rewrite(pack / "world.json", mutate) + with pytest.raises(WorldLoadError, match=r"world\.json width = 300 is out of band"): + load_world(pack) + + +def test_too_many_events_rejected(tmp_path: Path) -> None: + """An event table over the 500-row cap is rejected before it is decoded.""" + pack = _clone_pack(tmp_path) + + def mutate(data: dict[str, Any]) -> None: + filler = {"kind": "lore", "weight": 1, "text": "filler"} + data["events"] = [filler.copy() for _ in range(501)] + + _rewrite(pack / "events.json", mutate) + with pytest.raises(WorldLoadError, match=r"events\.json defines 501 events; the limit is 500"): + load_world(pack) + + +def test_overlong_monster_name_rejected(tmp_path: Path) -> None: + """A 49-character monster name is one past the 48-char display limit.""" + pack = _clone_pack(tmp_path) + + def mutate(data: list[dict[str, Any]]) -> None: + data[0]["name"] = "x" * 49 + + _rewrite(pack / "monsters.json", mutate) + with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] name is 49 characters"): + load_world(pack) diff --git a/examples/door-game/understone/__init__.py b/examples/door-game/understone/__init__.py index 0e198e07..dfca5bdf 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.3.0" +__version__ = "0.4.0" diff --git a/examples/door-game/understone/cli.py b/examples/door-game/understone/cli.py new file mode 100644 index 00000000..56505e8e --- /dev/null +++ b/examples/door-game/understone/cli.py @@ -0,0 +1,411 @@ +"""The pack-authoring command surface — validate a pack and scaffold a new one. + +This module is deliberately pure: it imports only the loader and the standard +library, takes no part in argument parsing (``server.main`` owns the argparse +front end), and writes to the streams it is handed. That keeps the authoring +loop — ``newpack`` then ``validate`` — testable as plain function calls. + +Two entry points back the two verbs: + +* :func:`cli_validate` loads a pack and, on success, prints a human-readable + report; on failure it prints the loader's author-facing message and returns + a non-zero code. This is the feedback half of the loop. +* :func:`cli_newpack` scaffolds a new pack: it copies the bundled world as a + starting template and writes an ``AUTHORING.md`` manual whose bands table is + generated from the loader's own band data, so the documented limits can + never drift from the enforced ones. +""" + +from __future__ import annotations + +import shutil +import sys +from typing import TYPE_CHECKING, TextIO + +from understone.errors import WorldLoadError +from understone.world import PACKAGED_WORLD_DIR, loader + +if TYPE_CHECKING: + from pathlib import Path + + from understone.engine.world import World + +# The six packaged content files copied verbatim as a new pack's template. +_PACK_FILES = ( + "terrain.json", + "monsters.json", + "items.json", + "locations.json", + "events.json", + "world.json", +) + + +def cli_validate(pack_dir: Path, out: TextIO | None = None, err: TextIO | None = None) -> int: + """Load *pack_dir* and report; return 0 if sound, 2 if it fails to load. + + On success a pack report is written to *out* and the function returns 0. + On any :class:`WorldLoadError` the loader's message — which names the + file, index, and field at fault — is written to *err* and the function + returns 2. The author iterates against that message until the pack loads. + + *out*/*err* default to the live ``sys.stdout``/``sys.stderr`` resolved at + call time, so a caller (or pytest's capture) may redirect them. + """ + out = out if out is not None else sys.stdout + err = err if err is not None else sys.stderr + try: + world = loader.load_world(pack_dir) + except WorldLoadError as exc: + print(f"The pack is flawed: {exc}", file=err) + return 2 + print(_pack_report(world), file=out) + return 0 + + +def cli_newpack(dest: Path, out: TextIO | None = None, err: TextIO | None = None) -> int: + """Scaffold a new content pack at *dest*; return 0, or 2 if *dest* is taken. + + Refuses to write into an existing non-empty directory (so an author never + clobbers work in progress). Otherwise it creates *dest*, copies the six + packaged content files as a starting template, and writes an + ``AUTHORING.md`` manual generated from the live loader bands. The author + then edits or regenerates the JSON and runs ``validate``. + + *out*/*err* default to the live ``sys.stdout``/``sys.stderr`` resolved at + call time, so a caller (or pytest's capture) may redirect them. + """ + out = out if out is not None else sys.stdout + err = err if err is not None else sys.stderr + if dest.exists() and dest.is_dir() and any(dest.iterdir()): + print(f"refusing to scaffold into non-empty directory: {dest}", file=err) + return 2 + if dest.exists() and not dest.is_dir(): + print(f"refusing to scaffold over a file: {dest}", file=err) + return 2 + + dest.mkdir(parents=True, exist_ok=True) + for name in _PACK_FILES: + shutil.copyfile(PACKAGED_WORLD_DIR / name, dest / name) + (dest / "AUTHORING.md").write_text(build_authoring_md(), encoding="utf-8") + + print(f"Scaffolded a new pack at {dest}.", file=out) + print("Six content files plus AUTHORING.md are in place; the template is the", file=out) + print("shipped Vale of Understone, ready to edit or regenerate.", file=out) + print(f"Next: edit or regenerate the JSON, then: understone validate {dest}", file=out) + return 0 + + +def _pack_report(world: World) -> str: + """Render the success report for a loaded *world*. + + Counts and shares are computed from the runtime world so the figures match + what the engine will actually run, not what the JSON nominally declares. + """ + settings = world.settings + boss_count = sum(1 for m in world.monsters if m.boss) + fight_share = _fight_share_pct(world) + + lines = [ + f"{world.name} — {world.width}x{world.height}", + f" monsters : {len(world.monsters)} ({boss_count} boss)", + f" items : {len(world.items)}", + f" zones : {len(world.zones)}", + f" events : {len(world.events)} ({fight_share}% fight by weight)", + ( + " settings : " + f"{settings.daily_turns} turns/day, " + f"bestow budget {settings.bestow_daily_budget}, " + f"Wyrm gate level {settings.wyrm_min_level}" + ), + "", + "This pack is sound. The door stands open.", + ] + return "\n".join(lines) + + +def _fight_share_pct(world: World) -> int: + """Return the share of overworld encounter weight that is a ``fight``. + + Reported by weight, not row count, because weight is the draw probability + the engine actually rolls against — it is the number an author tunes to hit + the ~55% fight feel. + """ + total = sum(e.weight for e in world.events) + if total == 0: + return 0 + fight = sum(e.weight for e in world.events if e.kind == "fight") + return round(100 * fight / total) + + +def build_authoring_md() -> str: + """Build the AUTHORING.md manual, bands table 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. + """ + return _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands()) + + +def _render_bands() -> str: + """Render the bands reference straight from the loader's band data.""" + parts: list[str] = [] + + parts.append("### Map and counts\n") + parts.append( + f"* Map width and height: each `{loader.MAP_DIM_MIN}`..`{loader.MAP_DIM_MAX}` cells." + ) + # monsters/items/events are their own files; locations and zones are lists + # inside world.json, so name each cap's real source. + count_source = { + "monsters": "`monsters.json`", + "items": "`items.json`", + "events": "`events.json`", + "locations": "`world.json` → `locations`", + "zones": "`world.json` → `zones`", + } + for name, cap in loader.MAX_COUNTS.items(): + parts.append(f"* {count_source[name]}: at most `{cap}` entries.") + parts.append( + f"* Display names (monster, item, location): at most " + f"`{loader.MAX_NAME_LEN}` printable characters." + ) + parts.append( + "* Map glyphs (terrain, location, legend keys): exactly one printable " + "character, and never one of " + + ", ".join(f"`{g}`" for g in _reserved_glyph_list()) + + " (the frame box-drawing lines and the `@`/`&` player markers)." + ) + parts.append("") + + parts.append("### Economy and progression settings (`world.json` → `settings`)\n") + parts.append("| field | allowed range |") + parts.append("| --- | --- |") + for field_name, (lo, hi) in loader.SETTINGS_BANDS.items(): + rng = f"{lo}..{hi}" if hi is not None else f"{lo} or more" + parts.append(f"| `{field_name}` | `{rng}` |") + parts.append("") + + parts.append("### Overworld event amounts (`events.json`, per kind)\n") + parts.append("| kind | min..max amount |") + parts.append("| --- | --- |") + for kind, (lo, hi) in loader.EVENT_AMOUNT_BANDS.items(): + parts.append(f"| `{kind}` | `{lo}..{hi}` |") + parts.append( + "\n(`fight` and `lore` carry no amount; `fight` draws its foe from the " + "zone tier band, `lore` is pure flavour text.)" + ) + + return "\n".join(parts) + + +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] + return box + actors + + +_AUTHORING_TEMPLATE = """\ +# Authoring a world pack for Understone + +A *world pack* is a directory of six JSON files that the server loads at start +to become the entire game world — its map, its monsters, its economy, its +endgame. There is no code to write: you describe a world as data, the loader +validates it hard, and the server runs it. This file is the manual; you can +follow it cold, by hand or with an LLM. + +The loop is short: + +1. `understone newpack mypack` — scaffold this template (you are reading the + copy it wrote into `mypack/AUTHORING.md`). +2. Edit or regenerate the JSON files to describe your world. +3. `understone validate mypack` — the loader checks the pack and either prints + a report ending **"This pack is sound. The door stands open."** or tells you + exactly which file, row, and field is wrong. +4. Repeat step 2 until it is sound, then serve it: + `UNDERSTONE_WORLD=mypack understone`. + +The loader's error messages are written FOR you: every failure names the file, +the index, and the field, and says what was expected. Treat them as the +feedback loop — iterate until the report says the door stands open. + +--- + +## The six files and how they fit together + +| file | shape | holds | +| --- | --- | --- | +| `terrain.json` | object keyed by legend char | terrain kinds: glyph, walkability, encounter rate | +| `monsters.json` | list | monster stat blocks, tiered; one flagged the boss | +| `items.json` | list | weapons, armour, consumables for the shop | +| `locations.json` | object keyed by location key | building kinds: name, glyph, menu actions, flavour | +| `events.json` | object with an `events` list | the weighted overworld encounter table | +| `world.json` | object | the map, placements, zones, and `settings` that bind it all | + +The cross-references the loader enforces: + +* every character in `world.json` → `legend` must name a terrain `key` from + `terrain.json`; every character in `terrain_rows` must be in that legend; +* every placement in `world.json` → `locations` must name a key defined in + `locations.json`, and must sit on walkable terrain; +* `settings.starting_weapon` / `starting_armor` must be ids from + `items.json`; `settings.boss_monster` must be an id from `monsters.json` + that is flagged `"boss": true`; +* every tier in `settings.dungeon_tiers` must be backed by a NON-boss monster; +* every zone's tier band must overlap at least one monster tier. + +--- + +## File-by-file schema + +### `terrain.json` + +An object whose keys are the single-character legend symbols used in the map. + +```json +{ + ".": {"key": "grass", "glyph": ".", "walkable": true, "encounter_rate": 0.1, "color": "floor"} +} +``` + +* `key` — internal name the map legend resolves to. +* `glyph` — the single character drawn on the map (see glyph rules below). +* `walkable` — may a player stand here. +* `encounter_rate` — `0.0`..`1.0`, the per-step chance a walk rolls the event + table on this terrain. +* `color` — a palette role string (`floor`, `tree`, `water`, `wall`, ...). + +### `monsters.json` + +A list of stat blocks. `tier` groups foes by difficulty; zones and the dungeon +gauntlet draw from tiers. Exactly one monster should be the boss. + +```json +{"tier": 2, "name": "Goblin", "hp": 12, "atk": 5, "def": 1, "xp": 18, "gold": 7} +``` + +The boss adds an `id` and `"boss": true`, and is referenced by +`settings.boss_monster`: + +```json +{"tier": 6, "name": "the Wyrm Below", "hp": 120, "atk": 24, "def": 8, + "xp": 400, "gold": 250, "boss": true, "id": "wyrm_below"} +``` + +### `items.json` + +A list of equipment and consumables. `slot` is `weapon`, `armor`, or +`consumable`. Weapons add `atk`, armour adds `def`, consumables `heal`. + +```json +{"id": "short_sword", "name": "Short Sword", "slot": "weapon", "atk": 5, "price": 40} +``` + +### `locations.json` + +An object keyed by location key. Each entry is a building kind with a menu of +`actions` the player may take inside it. + +```json +{ + "inn": {"kind": "inn", "name": "The Sleeping Drake", "glyph": "I", + "color": "town", "actions": ["rest", "leave"], + "flavor": ["Lamplight pools on worn oak tables."]} +} +``` + +The verbs the engine understands are `rest` (inn), `buy`/`sell` (shop), `heal` +(healer), `descend`/`challenge` (dungeon), and `leave`. Give each building the +menu that matches its role. + +### `events.json` + +An object with an `events` list — the weighted overworld encounter table the +server rolls as a player walks. + +```json +{"events": [ + {"kind": "fight", "weight": 82, "text": "Something snarls out of the brush."}, + {"kind": "gold", "weight": 8, "text": "a rotted coin-purse", "min": 4, "max": 12} +]} +``` + +* `kind` — `fight`, `gold`, `heal`, `trap`, or `lore`. +* `weight` — relative draw weight (`> 0`). +* `text` — required (non-empty) for every kind except `fight`. +* `min`/`max` — required for the value-bearing kinds (`gold`, `heal`, `trap`). + +There MUST be at least one `fight` row, or a walk could never find a monster. + +### `world.json` + +The binding file: `name`, `width`, `height`, `spawn` `[x, y]`, a `legend` +mapping characters to terrain keys, `terrain_rows` (one string per row, each +exactly `width` long), a `locations` list of `{"key", "x", "y"}` placements, +a `zones` list (rectangles that bias monster tiers), and a `settings` object. + +```json +{"key": "forest_near", "rect": [30, 18, 60, 36], "tier_lo": 1, "tier_hi": 2} +``` + +--- + +## The bands — the limits the loader enforces + +These are generated from the loader's own tables, so they are exactly what +`validate` checks. A value outside its band is a load error. + +{{BANDS}} + +--- + +## Design guidance + +**Turn economy.** `daily_turns` is the whole pacing lever: only fighting, +descending, and challenging the Wyrm spend a turn (moving, resting, shopping +are free). A small budget (the Vale uses 10) makes this a correspondence game +played a little each day. Set `rest_cost`, `heal_cost_per_hp`, and shop prices +so a day's gold roughly covers a day's recovery — too cheap and there is no +tension, too dear and a hero stalls. + +**Tier curve.** Lay monster tiers as a rising staircase: each tier should be a +real step up in `hp`/`atk` and a real step up in `xp`/`gold`, so the reward of +pushing into a harder zone pays for the risk. Keep two or three foes per tier +for variety. The boss should tower over the top random tier — it is the climax. + +**Encounter feel.** Aim for roughly 55% of overworld encounter WEIGHT on +`fight` rows; the rest is the texture of travel — small gold finds, healing +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`/`$`/`+`/`>`. + +**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 +faced only through the `challenge` verb, gated by `settings.wyrm_min_level`. A +boss tier must NOT appear in `settings.dungeon_tiers`: the gauntlet excludes +boss monsters, so a boss-only rung would be unfillable — back every dungeon +tier with at least one ordinary monster. + +**Location menus.** Give each building only the actions it can honour. An inn +that offers `buy` but no shop logic will confuse the narrator; match the menu +to the building's role. + +--- + +## The validate loop + +Run `understone validate mypack` after every change. On success you get a +report — name, size, monster/item/zone/event counts, fight share, and the key +settings — ending in **"This pack is sound. The door stands open."** On +failure you get one precise line naming the file, the row, and the field. + +The error messages are deliberately instructive: they are the authoring API. +Keep editing and re-validating until the door stands open, then point the +server at your pack with `UNDERSTONE_WORLD=mypack`. +""" diff --git a/examples/door-game/understone/server.py b/examples/door-game/understone/server.py index c8424383..b1b135d3 100644 --- a/examples/door-game/understone/server.py +++ b/examples/door-game/understone/server.py @@ -18,6 +18,8 @@ Usage:: understone # via entry point (stdio transport) python -m understone # via module + understone validate PATH # check a content pack loads cleanly + understone newpack PATH # scaffold a new pack + authoring manual Environment variables --------------------- @@ -31,6 +33,7 @@ UNDERSTONE_PATH HTTP path for the MCP endpoint (default: /mcp) from __future__ import annotations +import argparse import logging import os from pathlib import Path @@ -39,10 +42,11 @@ from typing import TYPE_CHECKING from mcp.server.fastmcp import FastMCP from starlette.responses import HTMLResponse, JSONResponse, Response -from understone import watch +from understone import cli, watch from understone.errors import WorldLoadError from understone.game import Game from understone.persistence import Store +from understone.world import PACKAGED_WORLD_DIR from understone.world.loader import load_world if TYPE_CHECKING: @@ -51,8 +55,6 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -_PACKAGED_WORLD = Path(__file__).resolve().parent / "world" / "data" - _PREMISE = ( "Understone is a shared-world BBS door game played through these tools. " "Call door_help first to learn how to narrate it." @@ -164,7 +166,7 @@ _GAME: Game | None = None def _build_game(watch_url: str | None = None) -> Game: """Construct the module Game from environment configuration.""" db_path = os.environ.get("UNDERSTONE_DB", "understone.db") - world_dir = os.environ.get("UNDERSTONE_WORLD") or str(_PACKAGED_WORLD) + world_dir = os.environ.get("UNDERSTONE_WORLD") or str(PACKAGED_WORLD_DIR) world = load_world(world_dir) store = Store(db_path) return Game(world, store, watch_url=watch_url) @@ -443,18 +445,19 @@ def create_app( when given, is the spectator page URL the join banner and help manual advertise; ``main`` derives it from the bind host/port. """ - world = load_world(world_dir or str(_PACKAGED_WORLD)) + world = load_world(world_dir or str(PACKAGED_WORLD_DIR)) store = Store(db_path) _set_game(Game(world, store, watch_url=watch_url)) return mcp.streamable_http_app() -def main() -> None: - """Run the Understone MCP server. +def _serve() -> None: + """Serve the Understone MCP world over the configured transport. Honours UNDERSTONE_TRANSPORT: "stdio" (default) or "streamable-http". For http, host/port/path are read from the environment and applied to the - FastMCP settings before serving. + FastMCP settings before serving. This is the actual transport launch; it is + kept separate from argument parsing so the parse step has no side effects. """ logging.basicConfig(level=logging.INFO) transport = os.environ.get("UNDERSTONE_TRANSPORT", "stdio") @@ -484,3 +487,40 @@ def main() -> None: except WorldLoadError as exc: raise SystemExit(f"failed to load world: {exc}") from exc mcp.run(transport="stdio") + + +def _build_parser() -> argparse.ArgumentParser: + """Build the ``understone`` argument parser: serve (default), validate, newpack. + + Parsing is deliberately free of side effects — no world load, no port bind — + so the resolved subcommand can be inspected without serving anything. + """ + parser = argparse.ArgumentParser( + prog="understone", + description=( + "Understone — a BBS-style ANSI door game served over MCP, plus the " + "tools to author its world packs." + ), + ) + sub = parser.add_subparsers(dest="cmd") + sub.add_parser("serve", help="serve the MCP world (the default with no command)") + validate = sub.add_parser("validate", help="validate a content pack and print a report") + validate.add_argument("path", type=Path, help="the pack directory to validate") + newpack = sub.add_parser("newpack", help="scaffold a new content pack from the bundled world") + newpack.add_argument("path", type=Path, help="the directory to create the pack in") + return parser + + +def main(argv: list[str] | None = None) -> None: + """Run the Understone command line: serve, or author a world pack. + + With no arguments (the entry point and ``python -m understone``) this serves + the MCP world exactly as before. ``validate PATH`` and ``newpack PATH`` drive + the pack-authoring loop and exit with the verb's status code. + """ + args = _build_parser().parse_args(argv) + if args.cmd == "validate": + raise SystemExit(cli.cli_validate(args.path)) + if args.cmd == "newpack": + raise SystemExit(cli.cli_newpack(args.path)) + _serve() diff --git a/examples/door-game/understone/world/__init__.py b/examples/door-game/understone/world/__init__.py index 09611689..92159bd6 100644 --- a/examples/door-game/understone/world/__init__.py +++ b/examples/door-game/understone/world/__init__.py @@ -1 +1,8 @@ """Content-pack loading — JSON on disk becomes a runtime ``World``.""" + +from pathlib import Path + +# The bundled starter pack ("The Vale of Understone"). Single source of truth +# for where packaged content lives — the server's default world and the +# scaffolder's template both resolve here. +PACKAGED_WORLD_DIR = Path(__file__).resolve().parent / "data" diff --git a/examples/door-game/understone/world/loader.py b/examples/door-game/understone/world/loader.py index 8d3b49f2..aa73902c 100644 --- a/examples/door-game/understone/world/loader.py +++ b/examples/door-game/understone/world/loader.py @@ -36,7 +36,7 @@ from understone.errors import WorldLoadError # Sanity bands for economy settings: (min, max) inclusive, or (min, None). # heal_cost_per_hp may be 0: in that config ALL healing in the world is free # (the healer included), so a free bestow-heal is economically coherent. -_SETTINGS_BANDS: dict[str, tuple[int, int | None]] = { +SETTINGS_BANDS: dict[str, tuple[int, int | None]] = { "daily_turns": (1, 100), "rest_cost": (0, None), "heal_cost_per_hp": (0, None), @@ -50,13 +50,84 @@ _SETTINGS_BANDS: dict[str, tuple[int, int | None]] = { } # Per-kind amount bands for the overworld event table (inclusive). -_EVENT_AMOUNT_BANDS: dict[str, tuple[int, int]] = { +EVENT_AMOUNT_BANDS: dict[str, tuple[int, int]] = { "gold": (1, 500), "trap": (1, 500), "heal": (1, 100), } _EVENT_KINDS = frozenset({"fight", "gold", "heal", "trap", "lore"}) +# Map-dimension band (inclusive). The floor keeps a map wide enough to frame a +# town; the ceiling caps the work a frame redraw and a row-decode must do on +# untrusted pack input. +MAP_DIM_MIN = 8 +MAP_DIM_MAX = 256 + +# Upper bound on each content list, so an oversized (or generated-runaway) pack +# fails loudly at load rather than ballooning memory. +MAX_COUNTS: dict[str, int] = { + "monsters": 500, + "items": 500, + "events": 500, + "locations": 500, + "zones": 500, +} + +# Display names render inside frames, menus, and the Herald, so cap their width. +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 +# other-player markers, so a map glyph must not impersonate an actor either. +_BOX_DRAWING_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. + """ + 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 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)" + ) + + +def _check_name(name: str, where: str, *, role: str = "name") -> None: + """Validate a display name: printable and within :data:`MAX_NAME_LEN`.""" + if not name.isprintable(): + raise WorldLoadError(f"{where} {role} {name!r} must be printable") + if len(name) > MAX_NAME_LEN: + raise WorldLoadError( + f"{where} {role} is {len(name)} characters; the limit is {MAX_NAME_LEN}" + ) + + +def _check_count(items: list[Any], name: str, where: str) -> None: + """Reject a content list longer than its :data:`MAX_COUNTS` cap. + + *name* keys the cap (and is the logical content kind shown in the manual); + *where* names the actual JSON source for the author-facing message, since + locations and zones live inside ``world.json`` rather than their own file. + """ + cap = MAX_COUNTS[name] + if len(items) > cap: + raise WorldLoadError(f"{where} defines {len(items)} {name}; the limit is {cap}") + def load_world(pack_dir: str | Path) -> World: """Load and validate the content pack at *pack_dir* into a ``World``.""" @@ -94,12 +165,10 @@ def _load_terrain(root: Path) -> dict[str, TerrainDef]: raise WorldLoadError("terrain.json must be an object keyed by legend character") out: dict[str, TerrainDef] = {} for key, spec in raw.items(): - if len(key) != 1: - raise WorldLoadError(f"terrain.json legend key {key!r} must be a single character") where = f"terrain.json[{key!r}]" + _check_glyph(key, where, role="legend key") glyph = str(_require(spec, "glyph", where)) - if len(glyph) != 1: - raise WorldLoadError(f"{where} glyph must be a single character, got {glyph!r}") + _check_glyph(glyph, where) rate = float(_require(spec, "encounter_rate", where)) if not 0.0 <= rate <= 1.0: raise WorldLoadError(f"{where} encounter_rate must be within 0.0..1.0, got {rate}") @@ -119,9 +188,12 @@ def _load_monsters(root: Path) -> list[Monster]: raw = _read_json(root, "monsters.json") if not isinstance(raw, list): raise WorldLoadError("monsters.json must be a list of monster objects") + _check_count(raw, "monsters", "monsters.json") out: list[Monster] = [] for i, spec in enumerate(raw): where = f"monsters.json[{i}]" + name = str(_require(spec, "name", where)) + _check_name(name, where) hp = int(_require(spec, "hp", where)) if hp < 1: raise WorldLoadError(f"{where} hp must be >= 1, got {hp}") @@ -135,7 +207,7 @@ def _load_monsters(root: Path) -> list[Monster]: out.append( Monster( tier=int(_require(spec, "tier", where)), - name=str(_require(spec, "name", where)), + name=name, hp=hp, atk=atk, def_=def_, @@ -154,9 +226,12 @@ def _load_items(root: Path) -> list[Item]: raw = _read_json(root, "items.json") if not isinstance(raw, list): raise WorldLoadError("items.json must be a list of item objects") + _check_count(raw, "items", "items.json") out: list[Item] = [] for i, spec in enumerate(raw): where = f"items.json[{i}]" + name = str(_require(spec, "name", where)) + _check_name(name, where) slot_raw = str(_require(spec, "slot", where)) try: slot = Slot(slot_raw) @@ -173,7 +248,7 @@ def _load_items(root: Path) -> list[Item]: out.append( Item( item_id=str(_require(spec, "id", where)), - name=str(_require(spec, "name", where)), + name=name, slot=slot, atk=atk, def_=def_, @@ -193,11 +268,8 @@ def _load_location_kinds(root: Path) -> dict[str, dict[str, Any]]: for key, spec in raw.items(): where = f"locations.json[{key!r}]" _require(spec, "kind", where) - _require(spec, "name", where) - _require(spec, "glyph", where) - glyph = str(spec["glyph"]) - if len(glyph) != 1: - raise WorldLoadError(f"{where} glyph must be a single character, got {glyph!r}") + _check_name(str(_require(spec, "name", where)), where) + _check_glyph(str(_require(spec, "glyph", where)), where) _require(spec, "actions", where) return raw @@ -216,6 +288,7 @@ def _load_events(root: Path) -> list[WorldEvent]: rows = _require(raw, "events", "events.json") if not isinstance(rows, list) or not rows: raise WorldLoadError("events.json 'events' must be a non-empty list of event objects") + _check_count(rows, "events", "events.json") out: list[WorldEvent] = [] has_fight = False @@ -249,7 +322,7 @@ def _decode_event_amount(spec: dict[str, Any], kind: str, where: str) -> tuple[i Value-bearing kinds (gold/heal/trap) must declare ``min``/``max`` within the per-kind band with ``min <= max``; fight/lore carry no amount. """ - band = _EVENT_AMOUNT_BANDS.get(kind) + band = EVENT_AMOUNT_BANDS.get(kind) if band is None: return 0, 0 band_lo, band_hi = band @@ -279,8 +352,11 @@ def _load_map( name = str(_require(raw, "name", "world.json")) width = int(_require(raw, "width", "world.json")) height = int(_require(raw, "height", "world.json")) - if width <= 0 or height <= 0: - raise WorldLoadError(f"world.json dimensions must be positive, got {width}x{height}") + for label, dim in (("width", width), ("height", height)): + if not MAP_DIM_MIN <= dim <= MAP_DIM_MAX: + raise WorldLoadError( + f"world.json {label} = {dim} is out of band ({MAP_DIM_MIN}..{MAP_DIM_MAX})" + ) legend = _require(raw, "legend", "world.json") if not isinstance(legend, dict): @@ -380,6 +456,7 @@ def _decode_locations( placements = _require(raw, "locations", "world.json") if not isinstance(placements, list): raise WorldLoadError("world.json locations must be a list of placements") + _check_count(placements, "locations", "world.json locations") out: list[LocationDef] = [] seen: set[tuple[int, int]] = set() for i, place in enumerate(placements): @@ -436,6 +513,7 @@ def _decode_zones( zones_raw = raw.get("zones", []) if not isinstance(zones_raw, list): raise WorldLoadError("world.json zones must be a list") + _check_count(zones_raw, "zones", "world.json zones") tiers = {m.tier for m in monsters} out: list[Zone] = [] for i, spec in enumerate(zones_raw): @@ -472,7 +550,7 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons raise WorldLoadError("world.json settings must be an object") values: dict[str, int] = {} - for field_name, (lo, hi) in _SETTINGS_BANDS.items(): + for field_name, (lo, hi) in SETTINGS_BANDS.items(): value = int(_require(spec, field_name, "world.json settings")) if value < lo or (hi is not None and value > hi): band = f"{lo}..{hi}" if hi is not None else f">= {lo}"