mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Understone — a BBS-style ANSI door game served over MCP."""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -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`.
|
||||
"""
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user