mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-20 11:00:59 -06:00
393a6fc2b2
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.
375 lines
14 KiB
Python
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
|