Files
turnstone/examples/door-game/tests/test_cli.py
T
Patrick Buckley 393a6fc2b2 feat(examples): Understone v0.10 — the satchel, the ore-forge, and the vault
A game-loop mechanics patch: the satchel becomes a real stacking inventory,
forging now demands ore won in combat (not just gold), and a vault lets a
hero protect coin from ambush.

- Stacking satchel: the bag re-encodes from a flat id list to "id:qty"
  stacks, so potions stack (three Minor Potions fill one slot, not three)
  and materials ride alongside. satchel_max now caps distinct KINDS (3);
  per-kind quantity is unbounded. quaff/death-save still pull the strongest
  potion and ignore materials. One pure codec (engine/satchel.py) owns the
  encoding; the façade, the Watch, and the sim all decode through it — no
  three-way drift (the v0.9 single-source lesson). The codec parses a bare
  id as qty 1, so it can never silently drop a malformed stack.
- Ore-gated forge: ore is a material that drops from won dungeon-rung
  fights (and, less often, forest fights), stacks in the satchel, and is
  not buyable or sellable — you earn your edge by fighting for it. Forging
  now costs gold AND ore ((plus+1) ore per tier), so a rich-but-idle hero
  can no longer buy power at the dice table. The dungeon is now also the
  mine.
- The vault: deposit/withdraw at the inn moves coin to a strongbox that
  ambush cannot touch and that SURVIVES the Wyrm-win legacy reset — the
  carry-vs-protect decision the PvP economy was missing.
- Surfaced on both the /watch lobby TV and the in-chat door_status sheet:
  each hero's stacked satchel, carried gold, and vaulted gold.
- Tuning (the sim is the instrument): the ore gate added ~2 days to the
  Vale and ~1.6 to the Cinder Wastes; the greedy bot still slays the Wyrm
  3/3 on both, fully forged to +3/+3, so the loop is not stalled. Defaults
  held — no numbers needed retuning.

Four new banded settings (forge_ore_item, forge_ore_per_plus,
ore_dungeon_drop, ore_forest_chance); both worlds gained an ore item.
Schema mutated in place (banked column, satchel re-encoding) — pre-1.0, no
migration by design; a real migration story is owed at 1.0. Tests 382 ->
419; the vault-survives-rebirth invariant and the codec are revert-verified.
2026-06-13 04:40:40 -07:00

375 lines
14 KiB
Python

"""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_authoring_md_has_width_rule_and_live_palette(tmp_path: Path) -> None:
"""AUTHORING.md documents the one-column rule and renders the live palette.
The width section states the Western-monospace assumption, and the safe
palette is generated from ``textwidth.SAFE_PALETTE`` (same can't-drift
pattern as the bands table) — every glyph appears, in a backticked cell.
"""
from understone.engine.textwidth import SAFE_PALETTE
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "## Glyph width" in manual
assert "exactly one terminal column" in manual
assert "Western monospace" in manual # the stated assumption
assert "Safe glyph palette" in manual
for glyph in SAFE_PALETTE:
assert f"`{glyph}`" in manual, f"palette glyph {glyph!r} missing from manual"
def test_cli_newpack_authoring_md_documents_action_sets(tmp_path: Path) -> None:
"""AUTHORING.md documents each building's real verb menu.
The per-building menus are an explicit table: the inn's `gamble` (v0.8) and
the v0.10 vault verbs `deposit`/`withdraw`, the shop's `forge`, and so on.
This pins the table rows and the "quaff anywhere" note so a doc regression
trips.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "| `inn` | `rest`, `deposit`, `withdraw`, `gamble`, `leave` |" in manual
assert "| `shop` | `buy`, `sell`, `forge`, `leave` |" in manual
assert "| `healer` | `heal`, `leave` |" in manual
assert "| `dungeon` | `descend`, `challenge`, `leave` |" in manual
assert "`quaff`" in manual and "legal **anywhere**" in manual
# The vault is described where its verbs are listed.
assert "VAULT" in manual and "SAFE from ambush" in manual
def test_cli_newpack_authoring_md_documents_ore_forge(tmp_path: Path) -> None:
"""AUTHORING.md documents the v0.10 ore-gated forge: material slot + settings.
The forge ore is a `material` item earned in combat; the four ore settings
(item, per-plus, dungeon drop, forest chance) are documented, and the band
figures are generated from the live loader so they cannot drift.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "`material`" in manual # the new slot
assert "forge_ore_item" in manual
assert "ore_forest_chance" in manual # the float setting (prose, not the band table)
# The two banded ore settings carry their LIVE bands.
lo, hi = loader.SETTINGS_BANDS["ore_dungeon_drop"]
assert f"`{lo}..{hi}`" in manual
assert "earns in combat" in manual or "earned in combat" in manual
def test_cli_newpack_authoring_md_states_color_advisory_and_spawn_walkable(
tmp_path: Path,
) -> None:
"""AUTHORING.md states color is advisory (loader does not validate it) and
that spawn must be on walkable terrain — both v0.8 honesty fixes."""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
# color is documented as advisory / not validated (it matches loader behaviour).
assert "advisory and not validated" in manual
# spawn's walkability requirement is now stated where spawn is introduced.
assert "must be on walkable terrain" in manual
def test_cli_newpack_authoring_md_color_roles_generated_from_enum(tmp_path: Path) -> None:
"""AUTHORING.md's colour-role vocabulary is generated from the Color enum.
The v0.9 fix: the assignable roles were hand-listed (and went stale — road
and the per-building roles were missing). They are now generated from
``Color.assignable()`` — the single source for the overlay-vs-assignable
split — so the manual lists exactly what the Watch can paint and cannot
drift. This asserts the NEW roles appear, that every assignable enum role
appears, and that the non-assignable roles (overlays + DEFAULT) are NOT
offered as author-assignable.
"""
from understone.screen.palette import Color
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
# A sampling of the new v0.9 roles is offered in the manual, backticked.
for role in ("road", "forest", "lava", "barren", "inn", "shop", "healer"):
assert f"`{role}`" in manual, f"new colour role {role!r} missing from manual"
# EVERY assignable enum role appears (generated, so the full set is present).
color_section = manual[manual.index("`color` — a palette role string") :].split("###", 1)[0]
for role in Color.assignable():
assert f"`{role.value}`" in manual, f"assignable role {role.value!r} missing from manual"
# The non-assignable roles (runtime overlays + the DEFAULT fallback) are NOT
# offered as terrain/location colours.
non_assignable = {c for c in Color} - set(Color.assignable())
assert Color.DEFAULT in non_assignable # the fallback is not author-pickable
for role in non_assignable:
assert f"`{role.value}`" not in color_section, (
f"non-assignable role {role.value!r} wrongly offered as author-assignable"
)
def test_cli_newpack_authoring_md_has_validate_coverage_split(tmp_path: Path) -> None:
"""AUTHORING.md honestly separates machine-enforced rules from eyeball-only.
The v0.8 subsection lists what `validate` DOES catch (including the two new
enforcements — rare-as-guardian and single-boss) and what it does NOT (chief
among them: location menu `actions` contents are unvalidated).
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "What `validate` checks, and what it cannot" in manual
# The newly-enforced rules are named in the DOES-catch list.
assert "Exactly one boss" in manual
assert "fixed rung guardian) must" in manual # rare-as-guardian enforcement
# The eyeball-only short list names the actions gap and the flavour caveat.
assert "Location menu `actions` contents" in manual
assert "Flavour and narration quality" 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_main_worlds_dispatch(capsys: pytest.CaptureFixture) -> None:
"""`understone worlds` routes through main, exits 0, and lists the Vale."""
with pytest.raises(SystemExit) as exc:
server.main(["worlds"])
assert exc.value.code == 0
out = capsys.readouterr().out
assert "vale" in out
assert "The Vale of Understone" in out
assert "UNDERSTONE_WORLD=" in out
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