feat(examples): Understone v0.4 — the authoring pipeline (worlds as data)

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.
This commit is contained in:
Patrick Buckley
2026-06-12 18:10:08 -07:00
parent d54110ffcb
commit d40c4c85ee
11 changed files with 912 additions and 29 deletions
+34
View File
@@ -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
+1 -1
View File
@@ -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"
+230
View File
@@ -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 <packaged dir>` 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
@@ -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:
+1 -1
View File
@@ -6,4 +6,4 @@ import understone
def test_version_present() -> None:
assert understone.__version__ == "0.3.0"
assert understone.__version__ == "0.4.0"
@@ -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)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.3.0"
__version__ = "0.4.0"
+411
View File
@@ -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`.
"""
+48 -8
View File
@@ -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()
@@ -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"
+95 -17
View File
@@ -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}"