diff --git a/examples/door-game/README.md b/examples/door-game/README.md index 67f4bb58..20411ad6 100644 --- a/examples/door-game/README.md +++ b/examples/door-game/README.md @@ -130,7 +130,9 @@ period **CRT spectator console** — a green-and-amber phosphor map of the whole world with every adventurer's `☻` marker, a live **Understone Herald** feed, the **Hall of Legends**, and a roster of who is currently abroad. It refreshes every couple of seconds; if it loses contact it dims and reads `SIGNAL LOST` until the -server returns. +server returns. The console's palette follows the pack: a world may pick its own +CRT colour with `settings.watch_theme` (`phosphor` green, `amber` gold, `ice` +blue, `ember` red), defaulting to the Vale's green if it says nothing. The Watch is **strictly read-only**. Input never flows through it — there are no controls, no forms, nothing that can change the world. It reads the same shared @@ -158,13 +160,15 @@ 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: +The loop has these 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 simulate mypack # play a greedy bot through it and measure the balance UNDERSTONE_WORLD=mypack understone # serve your world +understone worlds # list the bundled worlds and whether each is sound ``` `newpack` writes a starting template plus an `AUTHORING.md` manual — the @@ -174,13 +178,37 @@ 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. +`simulate` is the **balance instrument**: it drives a deliberately simple, +greedy bot through the *real* game — the same `join`/`move`/`action` calls the +tools make — over a seeded RNG and an injected clock, then prints a report +(final level, gold earned, fights fought, rungs cleared, whether and when the +Wyrm fell). It is a tuning probe, not a player to admire: it answers "is this +world *shaped* right, and is it *winnable*?". Pass `--days N`, `--seed S`, or +`--seeds K` for a multi-seed sweep with means and spreads. `worlds` lists every +bundled world — the default Vale plus any alternate packs shipped under +`understone/world/packs/` — loading each so it can report it as sound or flawed. + +**A second bundled world: The Cinder Wastes.** Understone ships a second world +alongside the Vale, in `understone/world/packs/cinder-wastes/` — a volcanic +ash-and-slag map whose Watch page glows ember-red instead of the Vale's green +phosphor. It is the pipeline's own dogfood: it was authored **by an LLM working +only from `AUTHORING.md` and the `validate` loop**, with no engine code touched, +then bundled verbatim. `understone worlds` lists it as sound, and +`understone simulate understone/world/packs/cinder-wastes --days 50 --seeds 3` +shows the greedy bot taking its Magma Wyrm — the end-to-end proof that a world +described purely as data, from the manual alone, is genuinely playable to +victory. Serve it with +`UNDERSTONE_WORLD=understone/world/packs/cinder-wastes understone`. + Packs are validated **hard** at load: every map glyph must render as exactly one terminal column (no fullwidth runes, no emoji, no combining marks — the frames are box-drawing rectangles) and 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 +character, a starting item, the boss monster, a dungeon tier) must resolve. The +loader also pins the rules that keep the endgame coherent: a world has exactly +one boss, and a dungeon tier's lead monster (its fixed rung guardian) may not be +a rare. 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. diff --git a/examples/door-game/pyproject.toml b/examples/door-game/pyproject.toml index b8d1c664..86227017 100644 --- a/examples/door-game/pyproject.toml +++ b/examples/door-game/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "understone" -version = "0.7.0" +version = "0.8.0" description = "Understone — a BBS-style ANSI door game served over MCP." requires-python = ">=3.11" license = "Apache-2.0" diff --git a/examples/door-game/tests/conftest.py b/examples/door-game/tests/conftest.py index 4c151a56..f7f29ac2 100644 --- a/examples/door-game/tests/conftest.py +++ b/examples/door-game/tests/conftest.py @@ -68,6 +68,7 @@ DEFAULT_SETTINGS = Settings( forge_base_cost=60, forge_max_plus=3, rare_drop_item="minor_potion", + watch_theme="phosphor", ) @@ -101,6 +102,7 @@ def make_settings(**overrides: object) -> Settings: "forge_base_cost": DEFAULT_SETTINGS.forge_base_cost, "forge_max_plus": DEFAULT_SETTINGS.forge_max_plus, "rare_drop_item": DEFAULT_SETTINGS.rare_drop_item, + "watch_theme": DEFAULT_SETTINGS.watch_theme, } base.update(overrides) return Settings(**base) # type: ignore[arg-type] diff --git a/examples/door-game/tests/test_cli.py b/examples/door-game/tests/test_cli.py index 6be9353e..3cac311a 100644 --- a/examples/door-game/tests/test_cli.py +++ b/examples/door-game/tests/test_cli.py @@ -150,6 +150,59 @@ def test_cli_newpack_authoring_md_has_width_rule_and_live_palette(tmp_path: Path assert f"`{glyph}`" in manual, f"palette glyph {glyph!r} missing from manual" +def test_cli_newpack_authoring_md_documents_v07_action_sets(tmp_path: Path) -> None: + """AUTHORING.md documents each building's real verb menu, gamble included. + + The v0.8 doc fix: the inn's live `gamble` verb was previously absent, and + the per-building menus are now an explicit table. 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`, `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 + + +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_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() @@ -203,6 +256,17 @@ def test_main_newpack_dispatch(tmp_path: Path) -> None: 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. diff --git a/examples/door-game/tests/test_package.py b/examples/door-game/tests/test_package.py index 76949e67..240be671 100644 --- a/examples/door-game/tests/test_package.py +++ b/examples/door-game/tests/test_package.py @@ -6,4 +6,4 @@ import understone def test_version_present() -> None: - assert understone.__version__ == "0.7.0" + assert understone.__version__ == "0.8.0" diff --git a/examples/door-game/tests/test_sim.py b/examples/door-game/tests/test_sim.py new file mode 100644 index 00000000..ca761c71 --- /dev/null +++ b/examples/door-game/tests/test_sim.py @@ -0,0 +1,306 @@ +"""Tests for the balance instrument (the greedy bot simulator). + +These run the REAL game façade end-to-end, so they double as the fiercest +integration test in the suite: determinism (same inputs → identical report), +that the greedy bot makes genuine progress over a Vale run, that its realized +fight share lands in a sane band, that a multi-seed sweep aggregates and the +report renders — and the single best end-to-end assertion, that a short seed +sweep actually SLAYS THE WYRM, proving the whole v0.1–v0.7 loop is winnable by +an unclever bot. +""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path +from typing import TYPE_CHECKING + +from understone import sim +from understone.engine.models import LocationDef, Mode, Zone +from understone.engine.rng import GameRNG +from understone.game import Game +from understone.persistence import Store +from understone.sim import BalanceReport, simulate + +from .conftest import make_monster, make_world + +if TYPE_CHECKING: + import pytest + +PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data" + + +# --------------------------------------------------------------------------- +# determinism +# --------------------------------------------------------------------------- + + +def test_same_inputs_give_identical_report() -> None: + """Same (pack, days, seed) → byte-identical BalanceReport (frozen + seeded).""" + a = simulate(PACK, 20, 5) + b = simulate(PACK, 20, 5) + assert a == b + assert isinstance(a, BalanceReport) + + +def test_different_seeds_diverge() -> None: + """Different seeds produce different runs (the RNG actually threads through).""" + a = simulate(PACK, 20, 1) + b = simulate(PACK, 20, 2) + # The runs are not identical (some headline measure differs). + assert (a.fights_fought, a.total_gold_earned, a.day_of_first_wyrm_kill) != ( + b.fights_fought, + b.total_gold_earned, + b.day_of_first_wyrm_kill, + ) + + +# --------------------------------------------------------------------------- +# progress +# --------------------------------------------------------------------------- + + +def test_bot_makes_progress_over_thirty_days() -> None: + """A 30-day Vale run climbs past level 1 and actually fights.""" + r = simulate(PACK, 30, 1) + assert r.final_level > 1 + assert r.fights_fought > 0 + assert r.total_gold_earned > 0 + # It also plumbs the deep — the rung ladder is reachable for a geared bot. + assert r.rungs_cleared > 0 + + +def test_realized_fight_share_in_sane_band() -> None: + """The bot's fight share is a real fraction and forest-fight dominant. + + A greedy XP grinder spends most of its turns fighting the wood (the rest are + the handful of descents and the Wyrm bout), so the share is high — but it is + a genuine fraction in (0, 1], never a degenerate 0 or a value out of range. + """ + r = simulate(PACK, 30, 3) + assert 0.0 < r.realized_fight_share <= 1.0 + # Fights dominate the turn-spend, but descents/challenges exist too, so the + # share is below a hard 1.0 floor only loosely — assert the sane half-band. + assert r.realized_fight_share >= 0.5 + + +# --------------------------------------------------------------------------- +# reporting & sweep +# --------------------------------------------------------------------------- + + +def test_report_renders_without_crashing() -> None: + r = simulate(PACK, 15, 1) + text = sim._render_report("The Vale of Understone", r) + assert "greedy bot" in text + assert "final level" in text + assert "Wyrm slain" in text + + +def test_cli_simulate_single_seed_renders(tmp_path: Path) -> None: + out = StringIO() + rc = sim.cli_simulate(PACK, 15, 1, out=out) + assert rc == 0 + assert "The Vale of Understone" in out.getvalue() + assert "fight share" in out.getvalue() + + +def test_cli_simulate_sweep_aggregates() -> None: + """A --seeds sweep prints per-seed lines plus an aggregate with spreads.""" + out = StringIO() + rc = sim.cli_simulate(PACK, 20, 1, out=out, seeds=3) + assert rc == 0 + text = out.getvalue() + assert "3 seeds" in text + assert "aggregate" in text + # Per-seed lines for each of the three seeds. + for seed in (1, 2, 3): + assert f"seed {seed:>3}" in text or f"seed {seed}" in text + # The aggregate carries a mean [min..max] spread. + assert "[" in text and "]" in text + + +def test_sweep_reports_are_each_deterministic() -> None: + """Each seed in a sweep is independently reproducible by single simulate.""" + seed = 4 + swept = simulate(PACK, 20, seed) + again = simulate(PACK, 20, seed) + assert swept == again + + +# --------------------------------------------------------------------------- +# the load-bearing assertion: the world is winnable +# --------------------------------------------------------------------------- + + +def test_greedy_bot_slays_the_wyrm() -> None: + """The single best end-to-end check: a short seed sweep KILLS THE WYRM. + + If a greedy, unclever bot can take the Wyrm Below playing through the real + façade, then the whole authored loop — movement, the zone-banded forest, the + economy, the rung ladder, the satchel death-save, the forge, and the endgame + gate — composes into a *winnable* game. A run that ever stops winning trips + here. A small sweep (not one lucky seed) so the proof is robust. + """ + reports = [simulate(PACK, 40, seed) for seed in (1, 2, 3)] + kills = [r for r in reports if r.wyrm_killed] + assert kills, "the greedy bot never slew the Wyrm across the seed sweep" + # Every kill records the day it first happened, within the run window. + for r in kills: + assert r.day_of_first_wyrm_kill is not None + assert 1 <= r.day_of_first_wyrm_kill <= 40 + + +# --------------------------------------------------------------------------- +# the bundled ALTERNATE world: The Cinder Wastes (LLM-authored from the manual) +# +# The Vale assertions above are the primary proof. These mirror them against the +# real bundled second world, so the dogfood pack — authored cold from AUTHORING.md +# — is held to the same bar: the bot must make genuine progress through it, and a +# short seed sweep must actually slay its Magma Wyrm. If the authored world ever +# stops being winnable, this trips. +# --------------------------------------------------------------------------- + +CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes" + + +def test_cinder_wastes_bot_makes_progress() -> None: + """A short Cinder Wastes run climbs past level 1 and genuinely plays. + + Fifteen days lands before the bot's first Wyrm kill (~day 24), so the level + is still climbing rather than reset post-win — a stable "the world plays" + signal across the durable measures (level, fights, gold, the rung ladder). + """ + r = simulate(CINDER, 15, 1) + assert r.final_level > 1 + assert r.fights_fought > 0 + assert r.total_gold_earned > 0 + assert r.rungs_cleared > 0 # the caldera rung ladder is reachable + + +def test_cinder_wastes_is_winnable() -> None: + """The dogfood proof: a greedy bot SLAYS THE MAGMA WYRM in the authored world. + + The Cinder Wastes was written by an LLM working only from AUTHORING.md and + the validator. This is the end-to-end demonstration that the manual plus the + loader produce not merely a *valid* pack but a *playable-to-victory* one — a + short seed sweep takes the Magma Wyrm. (It is harder than the Vale: the kill + lands later, so the window is wider than the Vale's.) + """ + reports = [simulate(CINDER, 50, seed) for seed in (1, 2, 3)] + kills = [r for r in reports if r.wyrm_killed] + assert kills, "the greedy bot never slew the Magma Wyrm across the seed sweep" + for r in kills: + assert r.day_of_first_wyrm_kill is not None + assert 1 <= r.day_of_first_wyrm_kill <= 50 + + +# --------------------------------------------------------------------------- +# robustness on non-shipped pack shapes: location doors inside hunt zones +# +# The bot runs arbitrary authored packs, not just the two bundled worlds, so a +# zone may overlap a location door. A door cell is "walkable" (you can step onto +# it) but standing on it flips the bot into that location's MENU — useless ground +# for a forest fight, and a "fight" issued from a MENU is rejected by the engine +# WITHOUT spending a turn. These pin the two guards that keep that from spinning +# the per-day loop or over-counting fights. +# --------------------------------------------------------------------------- + + +def _door(x: int, y: int) -> LocationDef: + """A bare location door placed at ``(x, y)`` (an inn, for concreteness).""" + return LocationDef( + key="inn", + kind="inn", + name="Wayhouse", + x=x, + y=y, + glyph="⌂", + color="town", + actions=("rest", "leave"), + ) + + +def test_nearest_in_zone_skips_a_door_cell() -> None: + """A door is never returned as a zone's hunt cell, even when it is nearest. + + The zone here spans a column running away from the spawn; its closest-to-spawn + walkable cell IS a location door, with open ground one step further. The + helper must skip the door (it would only trap the bot in a menu) and return + the open cell beyond it — the FIX-2 filter, mirroring ``_adjacent_open``. + """ + # 11x11 grass; spawn (5, 5). A door at (5, 6) is the nearest cell inside the + # zone (Manhattan 1); the nearest OPEN in-zone cell is (5, 7) (Manhattan 2). + world = make_world( + locations=[_door(5, 6)], + zones=[Zone(key="wood", x0=5, y0=6, x1=5, y1=9, tier_lo=1, tier_hi=1)], + ) + walkable = sim._reachable(world) + assert (5, 6) in walkable # the door cell is walkable... + cell = sim._nearest_in_zone(world, walkable, world.zones[0]) + assert cell is not None + assert cell != (5, 6) # ...but the helper does not pick it + assert world.location_at(*cell) is None # the returned cell is open ground + assert cell == (5, 7) # the nearest open in-zone cell beyond the door + + +def test_zone_hunt_spots_drops_a_zone_with_no_fightable_foe() -> None: + """A zone whose tier band holds no foe is dropped, not appended with None. + + FIX-4: the fallback in ``_best_hunt_spot`` (``ranked[-1]``) must never land on + a zone where no monster can roll. A zone banded to a tier with no monster is + simply not a hunting ground, so it never enters the spot list. + """ + # One zone banded to tier 9 (no monster lives there); the only monster is a + # tier-1 rat. The empty-band zone must be dropped entirely. + world = make_world( + monsters=[make_monster(tier=1)], + zones=[Zone(key="void", x0=4, y0=4, x1=6, y1=6, tier_lo=9, tier_hi=9)], + ) + spots = sim._zone_hunt_spots(world, sim._reachable(world)) + assert spots == [] # the foe-less zone is not a spot + + +def test_hunt_yields_the_turn_when_stuck_in_a_menu( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A hunt that ends in a MENU yields the turn instead of over-counting. + + The defence-in-depth for FIX-1: should the bot ever reach the fight moment + still inside a location MENU (a door swallowed the walk), the engine would + REJECT the "fight" without spending a turn — and the old string-only check + misread that reject as a won bout, over-counting and spinning the loop. The + new mode pre-check must instead leave the menu and return False (yield), so no + phantom fight is recorded and the day loop makes honest progress. + """ + # A door at (5, 4) inside a tier-1 zone. We inject this door cell as the hunt + # spot directly — the pre-FIX-2 state where a door WAS the nearest in-zone + # cell — so the guard, not the spot-selection filter, is what is under test. + world = make_world( + locations=[_door(5, 4)], + zones=[Zone(key="wood", x0=4, y0=3, x1=6, y1=5, tier_lo=1, tier_hi=1)], + monsters=[make_monster(tier=1)], + ) + clock = sim._Clock(sim._SIM_START) + game = Game(world, Store(tmp_path / "g.db"), clock=clock, rng=GameRNG(seed=1)) # type: ignore[arg-type] + bot = sim._Bot(game, world, clock) + game.join(bot.name) + bot._hunt_spots = [(1, (5, 4), make_monster(tier=1))] + player = game.players[bot.name] + + # Model "a location door swallowed the walk": every navigation step ends with + # the bot back inside the door's menu, so the hunt reaches its fight decision + # still in MENU mode no matter how many times it tries to step clear — exactly + # the trap the guard exists for (a single un-menu + re-walk cannot escape it). + def _walk_into_door(_goal: tuple[int, int]) -> None: + player.mode = Mode.MENU + player.at_location = "inn" + + monkeypatch.setattr(bot, "_goto_xy", _walk_into_door) + _walk_into_door((5, 4)) # start the hunt already inside the menu + + fought = bot._hunt() + + assert fought is False # the turn is yielded, not spent on a menu-reject + assert bot.fights_fought == 0 # no phantom fight recorded + assert game.players[bot.name].mode is Mode.TILE # and the menu was left behind diff --git a/examples/door-game/tests/test_watch_theme.py b/examples/door-game/tests/test_watch_theme.py new file mode 100644 index 00000000..ec7a5487 --- /dev/null +++ b/examples/door-game/tests/test_watch_theme.py @@ -0,0 +1,143 @@ +"""Tests for the per-pack Watch CRT theme (v0.8). + +Covers the loader band (each of the four legal themes loads; an unknown theme +is rejected naming the legal set; an omitted theme defaults to phosphor), the +state-payload carrying the theme, and the WATCH_HTML page's JS THEME table — +including the load-bearing guard that the "phosphor" values byte-match the +original ``:root`` CSS, so the bundled Vale stays visually identical. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest + +from understone import watch +from understone.errors import WorldLoadError +from understone.world.loader import ( + DEFAULT_WATCH_THEME, + WATCH_THEMES, + load_world, +) + +SHIPPED = Path(__file__).resolve().parents[1] / "understone" / "world" / "data" + +# The original :root CRT custom-property values (pre-v0.8). The "phosphor" theme +# MUST reproduce these byte-for-byte so the default Vale is pixel-identical. +_ORIGINAL_ROOT = { + "--phosphor": "#7dffa0", + "--phosphor-dim": "#2f7a46", + "--amber": "#ffb44d", + "--bg": "#050a06", + "--panel": "#0a140d", + "--edge": "#163a22", +} + + +def _pack_with_theme(tmp_path: Path, theme: Any) -> Path: + """Clone the Vale into a temp pack with ``settings.watch_theme`` set/removed. + + ``theme`` set to a string writes that value; set to the sentinel ``...`` + DELETES the key entirely (to exercise the omitted-defaults path). + """ + dest = tmp_path / "themed" + shutil.copytree(SHIPPED, dest) + world_json = dest / "world.json" + data = json.loads(world_json.read_text(encoding="utf-8")) + if theme is ...: + data["settings"].pop("watch_theme", None) + else: + data["settings"]["watch_theme"] = theme + world_json.write_text(json.dumps(data), encoding="utf-8") + return dest + + +# --------------------------------------------------------------------------- +# loader band +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("theme", sorted(WATCH_THEMES)) +def test_each_legal_theme_loads(tmp_path: Path, theme: str) -> None: + pack = _pack_with_theme(tmp_path, theme) + world = load_world(pack) + assert world.settings.watch_theme == theme + + +def test_unknown_theme_rejected_naming_the_set(tmp_path: Path) -> None: + pack = _pack_with_theme(tmp_path, "ultraviolet") + with pytest.raises(WorldLoadError) as exc: + load_world(pack) + message = str(exc.value) + assert "watch_theme" in message + assert "ultraviolet" in message + # The friendly message lists every legal theme so the author can fix it. + for name in WATCH_THEMES: + assert name in message + + +def test_omitted_theme_defaults_to_phosphor(tmp_path: Path) -> None: + pack = _pack_with_theme(tmp_path, ...) # delete the key entirely + world = load_world(pack) + assert world.settings.watch_theme == DEFAULT_WATCH_THEME == "phosphor" + + +def test_shipped_vale_is_phosphor() -> None: + """The bundled Vale ships the phosphor theme (its green is unchanged).""" + world = load_world(SHIPPED) + assert world.settings.watch_theme == "phosphor" + + +# --------------------------------------------------------------------------- +# payload + WATCH_HTML +# --------------------------------------------------------------------------- + + +def test_world_payload_carries_theme(tmp_path: Path) -> None: + pack = _pack_with_theme(tmp_path, "ice") + world = load_world(pack) + payload = watch.build_world_payload(world) + assert payload["theme"] == "ice" + + +def test_shipped_payload_theme_is_phosphor() -> None: + world = load_world(SHIPPED) + payload = watch.build_world_payload(world) + assert payload["theme"] == "phosphor" + + +def test_watch_html_has_theme_table_and_all_names() -> None: + """The page carries a JS THEME table keyed by every legal theme name.""" + html = watch.WATCH_HTML + assert "var THEMES" in html + assert "applyTheme" in html + for name in WATCH_THEMES: + # Each theme is a JS object key, e.g. ``phosphor: {``. + assert f"{name}: {{" in html, f"theme {name!r} missing from THEME table" + + +def test_watch_html_phosphor_values_byte_match_original_root() -> None: + """The "phosphor" theme reproduces the original :root values exactly. + + This is the load-bearing guard for "the Vale looks identical": every + original custom-property value still appears in the page (in the :root block + AND the THEME table), so swapping in the phosphor theme is a no-op repaint. + """ + html = watch.WATCH_HTML + for prop, value in _ORIGINAL_ROOT.items(): + # The value lives both in the :root CSS and the phosphor theme entry. + assert html.count(value) >= 2, f"{prop} value {value} not byte-matched twice" + # And the phosphor theme maps the property to exactly that value. + assert f'"{prop}": "{value}"' in html, f"phosphor {prop} != {value}" + + +def test_watch_html_applies_theme_on_world_fetch() -> None: + """The page applies the theme when world.json arrives (in paintMap).""" + html = watch.WATCH_HTML + assert "applyTheme(world.theme)" in html + # It swaps CSS custom properties on the document root. + assert "documentElement.style.setProperty" in html diff --git a/examples/door-game/tests/test_world_loader.py b/examples/door-game/tests/test_world_loader.py index dcef96b5..a9db36dd 100644 --- a/examples/door-game/tests/test_world_loader.py +++ b/examples/door-game/tests/test_world_loader.py @@ -181,7 +181,7 @@ def test_dungeon_tier_without_monster_rejected(tmp_path: Path) -> None: data["settings"]["dungeon_tiers"] = [4, 9] _rewrite(pack / "world.json", mutate) - with pytest.raises(WorldLoadError, match=r"dungeon_tiers\[1\] = 9 has no monster"): + with pytest.raises(WorldLoadError, match=r"dungeon_tiers\[1\] = 9 has no non-boss monster"): load_world(pack) @@ -202,6 +202,9 @@ def test_dungeon_tier_backed_only_by_boss_rejected(tmp_path: Path) -> None: Tier 6 in the shipped pack holds only the Wyrm Below (a boss). A gauntlet rung at tier 6 would draw from monsters_for_tier_band, which filters bosses out, so the rung silently does nothing — the loader must reject it instead. + The message says "no NON-boss monster" (not merely "no monster"): the boss + is present at that tier, it just cannot fill a rung, and the wording must + point the author at exactly that. """ pack = _clone_pack(tmp_path) @@ -209,7 +212,7 @@ def test_dungeon_tier_backed_only_by_boss_rejected(tmp_path: Path) -> None: data["settings"]["dungeon_tiers"] = [4, 6] # 6 is the boss-only tier _rewrite(pack / "world.json", mutate) - with pytest.raises(WorldLoadError, match=r"dungeon_tiers\[1\] = 6 has no monster"): + with pytest.raises(WorldLoadError, match=r"dungeon_tiers\[1\] = 6 has no non-boss monster"): load_world(pack) @@ -657,3 +660,95 @@ def test_monster_weight_and_rare_default_when_omitted(tmp_path: Path) -> None: rat = next(m for m in world.monsters if m.name == "Field Rat") assert rat.weight == 10 # the default biasing weight assert rat.rare is False + + +# --------------------------------------------------------------------------- +# v0.8 loader hardening: rare-as-rung-guardian and the single-boss invariant +# +# AUTHORING states both as rules; v0.8 makes them machine-checked. A rare in +# the lead slot of a dungeon tier would be silently promoted to a fixed rung +# guardian (and pulled from the rare pool); a stray second boss would validate +# clean yet make "the one endgame foe" a lie. +# --------------------------------------------------------------------------- + + +def test_rare_as_first_dungeon_tier_monster_rejected(tmp_path: Path) -> None: + """A rare in the FIRST slot of a dungeon tier becomes a fixed guardian — rejected. + + Tier 3 backs a ``dungeon_tiers`` rung and its first monster (the Forest + Wolf) is the rung guardian (``band[0]``). Flagging that lead monster rare + would quietly turn the rare into the fixed, repeatable guardian and remove + it from the weighted rare roll, so the loader rejects it by name. + """ + pack = _clone_pack(tmp_path) + + def mutate(data: list[dict[str, Any]]) -> None: + wolf = next(m for m in data if m["name"] == "Forest Wolf") # first tier-3 + wolf["rare"] = True + + _rewrite(pack / "monsters.json", mutate) + with pytest.raises( + WorldLoadError, + match=r"'Forest Wolf' is rare but is the first tier-3 monster.*fixed guardian", + ): + load_world(pack) + + +def test_rare_after_guardian_in_dungeon_tier_accepted(tmp_path: Path) -> None: + """A rare placed AFTER the guardian in the same dungeon tier loads cleanly. + + The shipped pack already does exactly this (the Hollow Knight is the third + tier-3 entry, behind the Forest Wolf guardian). Inserting another rare also + after the guardian must not trip the new check — only the LEAD slot of a + dungeon tier is constrained. + """ + pack = _clone_pack(tmp_path) + + def mutate(data: list[dict[str, Any]]) -> None: + # Splice a second tier-3 rare in just before the boss (well after the + # tier-3 guardian), so the tier's first non-boss monster is unchanged. + extra = { + "tier": 3, + "name": "the Ashen Stalker", + "hp": 26, + "atk": 10, + "def": 3, + "xp": 55, + "gold": 75, + "weight": 1, + "rare": True, + } + data.insert(len(data) - 1, extra) + + _rewrite(pack / "monsters.json", mutate) + world = load_world(pack) # no WorldLoadError: the rare is not the lead foe + tier3 = world.monsters_for_tier_band(3, 3) + assert tier3[0].name == "Forest Wolf" # the guardian is still the non-rare lead + assert any(m.name == "the Ashen Stalker" and m.rare for m in tier3) + + +def test_two_bosses_rejected(tmp_path: Path) -> None: + """Two ``boss``-flagged monsters are rejected: a world has exactly one boss.""" + pack = _clone_pack(tmp_path) + + def mutate(data: list[dict[str, Any]]) -> None: + # Give the Field Rat the boss flag too; now two monsters claim the role. + rat = next(m for m in data if m["name"] == "Field Rat") + rat["boss"] = True + rat["id"] = "field_rat" + + _rewrite(pack / "monsters.json", mutate) + with pytest.raises(WorldLoadError, match=r"flags 2 monsters as .boss.* true"): + load_world(pack) + + +def test_single_boss_accepted() -> None: + """The shipped pack carries exactly one boss and loads — the single-boss path. + + The positive half of the invariant: the Wyrm Below is the only boss, so the + load succeeds and the boss count is exactly one. + """ + world = load_world(SHIPPED) + bosses = [m for m in world.monsters if m.boss] + assert len(bosses) == 1 + assert bosses[0].name == "the Wyrm Below" diff --git a/examples/door-game/tests/test_worlds.py b/examples/door-game/tests/test_worlds.py new file mode 100644 index 00000000..08a53c69 --- /dev/null +++ b/examples/door-game/tests/test_worlds.py @@ -0,0 +1,206 @@ +"""Tests for bundled-world discovery and the ``worlds`` listing. + +Covers the discovery helper (the Vale leads, alternate packs follow +alphabetically, non-pack directories are skipped) and the ``cli_worlds`` +listing it backs: a sound fixture pack reports "sound", a deliberately-flawed +fixture pack reports "flawed", and the Vale is always listed first. The +``packs/`` directory is monkeypatched to a temp fixture tree so these tests +never depend on the real (separately-authored) second world. +""" + +from __future__ import annotations + +import json +import shutil +from io import StringIO +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from understone import cli +from understone import world as world_pkg +from understone.world import VALE_SLUG, bundled_world_dirs + +if TYPE_CHECKING: + import pytest + +SHIPPED = Path(__file__).resolve().parents[1] / "understone" / "world" / "data" + + +def _make_packs(tmp_path: Path, *, sound: list[str], flawed: dict[str, Any]) -> Path: + """Build a temp ``packs/`` tree: sound slugs plus flawed-world slugs. + + Each sound slug is a verbatim copy of the shipped Vale; each flawed slug is + a copy whose ``world.json`` is patched with the given settings overrides so + it fails to load. Returns the packs root to monkeypatch ``PACKS_DIR`` onto. + """ + packs = tmp_path / "packs" + packs.mkdir() + for slug in sound: + shutil.copytree(SHIPPED, packs / slug) + for slug, overrides in flawed.items(): + dest = packs / slug + shutil.copytree(SHIPPED, dest) + world_json = dest / "world.json" + data = json.loads(world_json.read_text(encoding="utf-8")) + data["settings"].update(overrides) + world_json.write_text(json.dumps(data), encoding="utf-8") + return packs + + +# --------------------------------------------------------------------------- +# bundled_world_dirs discovery +# --------------------------------------------------------------------------- + + +def test_bundled_world_dirs_vale_leads_then_alpha( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + packs = _make_packs(tmp_path, sound=["zephyr", "ashfall"], flawed={}) + monkeypatch.setattr(world_pkg, "PACKS_DIR", packs) + + found = bundled_world_dirs() + slugs = [slug for slug, _ in found] + # The Vale is always first; alternates follow alphabetically. + assert slugs == [VALE_SLUG, "ashfall", "zephyr"] + # The Vale entry points at the packaged data dir, not a packs subdir. + assert found[0][1] == world_pkg.PACKAGED_WORLD_DIR + + +def test_bundled_world_dirs_skips_non_pack_entries( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + packs = _make_packs(tmp_path, sound=["real"], flawed={}) + # A README placeholder and a directory with no world.json are NOT worlds. + (packs / "README.md").write_text("placeholder", encoding="utf-8") + (packs / "empty_dir").mkdir() + monkeypatch.setattr(world_pkg, "PACKS_DIR", packs) + + slugs = [slug for slug, _ in bundled_world_dirs()] + assert slugs == [VALE_SLUG, "real"] + + +def test_bundled_world_dirs_handles_absent_packs_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing packs/ directory yields just the Vale (never raises).""" + monkeypatch.setattr(world_pkg, "PACKS_DIR", tmp_path / "does_not_exist") + found = bundled_world_dirs() + assert [slug for slug, _ in found] == [VALE_SLUG] + + +# --------------------------------------------------------------------------- +# cli_worlds listing +# --------------------------------------------------------------------------- + + +def test_cli_worlds_lists_vale_sound_first(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(world_pkg, "PACKS_DIR", tmp_path / "empty") + out, err = StringIO(), StringIO() + rc = cli.cli_worlds(out=out, err=err) + + assert rc == 0 + text = out.getvalue() + lines = [ln for ln in text.splitlines() if ln.strip()] + # The very first listing line is the Vale, reported sound, with its size. + assert lines[0].split()[0] == VALE_SLUG + assert "The Vale of Understone" in lines[0] + assert "96x48" in lines[0] + assert "sound" in lines[0] + # The serve hint closes the listing. + assert "UNDERSTONE_WORLD=" in text + assert "the default Vale needs no setting" in text + + +def test_cli_worlds_reports_sound_alternate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + packs = _make_packs(tmp_path, sound=["mirefen"], flawed={}) + monkeypatch.setattr(world_pkg, "PACKS_DIR", packs) + out = StringIO() + cli.cli_worlds(out=out) + + text = out.getvalue() + line = next(ln for ln in text.splitlines() if ln.strip().startswith("mirefen")) + assert "sound" in line + assert "flawed" not in line + + +def test_cli_worlds_flags_flawed_alternate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # daily_turns 0 is out of its 1..100 band: the pack fails to load. + packs = _make_packs(tmp_path, sound=["sound_one"], flawed={"broken": {"daily_turns": 0}}) + monkeypatch.setattr(world_pkg, "PACKS_DIR", packs) + out = StringIO() + rc = cli.cli_worlds(out=out) + + assert rc == 0 # a flawed pack is reported, never fatal + text = out.getvalue() + broken_line = next(ln for ln in text.splitlines() if ln.strip().startswith("broken")) + assert "flawed:" in broken_line + assert "daily_turns" in broken_line # the offending field surfaces + # The sound pack alongside it still reports sound — one bad pack doesn't + # poison the survey. + sound_line = next(ln for ln in text.splitlines() if ln.strip().startswith("sound_one")) + assert "sound" in sound_line + + +def test_cli_worlds_vale_sorts_before_flawed_alternate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Even with an alphabetically-earlier flawed pack, the Vale leads.""" + packs = _make_packs(tmp_path, sound=[], flawed={"aaa_broken": {"start_hp": 0}}) + monkeypatch.setattr(world_pkg, "PACKS_DIR", packs) + out = StringIO() + cli.cli_worlds(out=out) + + lines = [ln for ln in out.getvalue().splitlines() if ln.strip()] + assert lines[0].split()[0] == VALE_SLUG + assert lines[1].strip().startswith("aaa_broken") + assert "flawed:" in lines[1] + + +# --------------------------------------------------------------------------- +# the REAL bundled alternate world (no monkeypatch): The Cinder Wastes +# +# The tests above stub PACKS_DIR to a fixture tree so they never depend on the +# separately-authored pack. These two exercise the actual shipped packs/ — the +# bundled Cinder Wastes must discover, load, validate, and appear in the listing +# as sound, so a broken or unbundled alternate trips here. +# --------------------------------------------------------------------------- + +CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes" + + +def test_bundled_cinder_wastes_loads_and_validates() -> None: + """The bundled Cinder Wastes loads through the (strict v0.8) loader cleanly. + + It is LLM-authored from AUTHORING.md alone, so this is the dogfood proof + that the manual + validator produce a pack the real loader accepts — and, + after v0.8, one that passes the stricter rare-as-guardian and single-boss + checks (its rares sit after their guardians; it has exactly one boss). + """ + from understone.world.loader import load_world + + world = load_world(CINDER) + assert world.name == "The Cinder Wastes" + assert world.settings.watch_theme == "ember" # the thematic ember CRT palette + bosses = [m for m in world.monsters if m.boss] + assert len(bosses) == 1 and bosses[0].name == "the Magma Wyrm" + # The boss id resolves and is the declared endgame foe. + assert world.settings.boss_monster == "magma_wyrm" + + +def test_cli_worlds_lists_bundled_cinder_wastes_sound() -> None: + """`understone worlds` discovers the real bundled Cinder Wastes as sound. + + No monkeypatch: this runs against the actual packs/ directory, so it asserts + the genuinely-shipped second world appears in the listing (alongside the + fixture-based listing tests above, which stay). + """ + out = StringIO() + rc = cli.cli_worlds(out=out) + + assert rc == 0 + line = next(ln for ln in out.getvalue().splitlines() if ln.strip().startswith("cinder-wastes")) + assert "The Cinder Wastes" in line + assert "sound" in line + assert "flawed" not in line diff --git a/examples/door-game/understone/__init__.py b/examples/door-game/understone/__init__.py index 17fabd42..fbfb2601 100644 --- a/examples/door-game/understone/__init__.py +++ b/examples/door-game/understone/__init__.py @@ -1,3 +1,3 @@ """Understone — a BBS-style ANSI door game served over MCP.""" -__version__ = "0.7.0" +__version__ = "0.8.0" diff --git a/examples/door-game/understone/cli.py b/examples/door-game/understone/cli.py index 81e61daa..708264b1 100644 --- a/examples/door-game/understone/cli.py +++ b/examples/door-game/understone/cli.py @@ -5,7 +5,7 @@ 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: +Three entry points back the three 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 @@ -14,6 +14,9 @@ Two entry points back the two verbs: 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. +* :func:`cli_worlds` lists the bundled worlds — the default Vale plus every + alternate pack shipped under ``world/packs/`` — loading each so it can report + whether it is sound or flawed, the discovery seam for "worlds without authors". """ from __future__ import annotations @@ -24,7 +27,7 @@ from typing import TYPE_CHECKING, TextIO from understone.engine.textwidth import SAFE_PALETTE from understone.errors import WorldLoadError -from understone.world import PACKAGED_WORLD_DIR, loader +from understone.world import PACKAGED_WORLD_DIR, bundled_world_dirs, loader if TYPE_CHECKING: from pathlib import Path @@ -97,6 +100,57 @@ def cli_newpack(dest: Path, out: TextIO | None = None, err: TextIO | None = None return 0 +def cli_worlds(out: TextIO | None = None, err: TextIO | None = None) -> int: + """List every bundled world, reporting each as sound or flawed; return 0. + + Discovers the worlds through :func:`~understone.world.bundled_world_dirs` + (the default Vale first, then the alternate packs alphabetically) and loads + each one. Each world is one line — its slug, name, ``WxH``, and either + ``sound`` or ``flawed: `` — so a shipped pack that has gone + out of band is visible at a glance rather than only failing at serve time. + A flawed world is reported, not fatal: the listing always returns 0 and + always ends with the hint for serving an alternate. *err* is accepted for a + uniform signature with the other verbs; the listing writes only to *out*. + """ + out = out if out is not None else sys.stdout + for slug, world_dir in bundled_world_dirs(): + print(_world_line(slug, world_dir), file=out) + print("", file=out) + print( + "Serve one with UNDERSTONE_WORLD= (or the default Vale needs no setting).", + file=out, + ) + return 0 + + +def _world_line(slug: str, world_dir: Path) -> str: + """Render one ``worlds`` listing line for the world at *world_dir*. + + Loads the world to report its real name, dimensions, and soundness. A pack + that fails to load is summarised as ``flawed: `` using the loader's + own author-facing message (truncated to keep the listing to one line per + world), never raised — the listing surveys every bundled world even when one + is broken. + """ + try: + world = loader.load_world(world_dir) + except WorldLoadError as exc: + return f" {slug:<10} flawed: {_short_reason(str(exc))}" + return f" {slug:<10} {world.name} — {world.width}x{world.height} — sound" + + +# How much of a loader error message the one-line ``worlds`` summary keeps. +_FLAW_REASON_MAX = 70 + + +def _short_reason(message: str) -> str: + """Trim a loader error to a single readable clause for the worlds listing.""" + flattened = " ".join(message.split()) + if len(flattened) <= _FLAW_REASON_MAX: + return flattened + return flattened[: _FLAW_REASON_MAX - 1].rstrip() + "…" + + def _pack_report(world: World) -> str: """Render the success report for a loaded *world*. @@ -148,7 +202,8 @@ def build_authoring_md() -> str: enforces and admits, and cannot silently drift from it. """ md = _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands()) - return md.replace("{{PALETTE}}", _render_palette()) + md = md.replace("{{PALETTE}}", _render_palette()) + return md.replace("{{VALIDATE_COVERAGE}}", _render_validate_coverage()) def _render_bands() -> str: @@ -198,7 +253,19 @@ def _render_bands() -> str: 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.)" + "zone tier band, `lore` is pure flavour text.)\n" + ) + + parts.append("### Watch theme (`world.json` → `settings.watch_theme`)\n") + legal = ", ".join(f"`{name}`" for name in sorted(loader.WATCH_THEMES)) + parts.append( + f"OPTIONAL. The CRT palette the live Watch page paints your world in, " + f"one of: {legal}. It defaults to `{loader.DEFAULT_WATCH_THEME}` (the " + f"original green phosphor), so you may leave it out entirely — a pack " + f"that omits it looks exactly as the bundled Vale always has. Set it to " + f"give your world its own colour: `amber` is a warm gold monitor, `ice` " + f"a cold pale blue, `ember` a hot red/orange. An unknown name is a load " + f"error naming the legal set." ) return "\n".join(parts) @@ -226,6 +293,46 @@ def _render_palette() -> str: ) +def _render_validate_coverage() -> str: + """Render the list of rules the loader actually enforces, generated from it. + + The figures that can drift (the number of banded settings, the name-length + cap, the reserved glyphs) are read from the live loader so the list cannot + fall out of step with what `validate` does; the prose names each family of + check. This is the machine-enforced half of the honesty split in the manual + — the eyeball-only half is hand-written below it, because "is the fiction + any good" is exactly what the loader can never see. + """ + settings_count = len(loader.SETTINGS_BANDS) + reserved = ", ".join(f"`{g}`" for g in _reserved_glyph_list()) + bullets = [ + f"* **Economy and progression bands** — every one of the {settings_count} " + "`settings` fields must sit in its allowed range (the table above), and " + "`growth` must be present and non-negative.", + "* **Glyph safety** — every terrain, location, and legend glyph must render " + f"exactly one column and must not be a reserved marker ({reserved}).", + "* **Map integrity** — `width`/`height` in band, every `terrain_rows` row " + "exactly `width` long with `height` rows, and every row character in the " + "`legend`.", + "* **Walkability** — `spawn` and every placed location must sit on walkable " + "terrain (and no two locations share a cell).", + f"* **Display-name length** — every monster, item, and location name within " + f"`{loader.MAX_NAME_LEN}` printable characters; content lists within their caps.", + "* **The fight row** — `events.json` must hold at least one `fight` entry, " + "with weights `> 0`, `min <= max`, and amounts in their per-kind band.", + "* **Cross-references** — `legend` → terrain key, location placements → " + "`locations.json` keys, `starting_weapon`/`starting_armor` → item ids, " + '`boss_monster` → a monster flagged `"boss": true`, and ' + "`rare_drop_item` → a consumable item id.", + "* **Zone tiers** — every zone's tier band must overlap at least one monster tier.", + "* **Dungeon ladder** — every `dungeon_tiers` tier must have a non-boss " + "monster, and that tier's FIRST monster (its fixed rung guardian) must " + "not be `rare`.", + '* **Exactly one boss** — at most one monster may carry `"boss": true`.', + ] + return "\n".join(bullets) + + _AUTHORING_TEMPLATE = """\ # Authoring a world pack for Understone @@ -295,7 +402,12 @@ An object whose keys are the single-character legend symbols used in the map. * `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`, ...). +* `color` — a palette role string. It is **advisory and not validated**: the + loader stores it but the v1 text renderer draws glyphs only, so any string + loads and an unrecognised role simply maps to the default at render time. The + roles a future colour renderer will recognise are `floor`, `wall`, `water`, + `tree`, `town`, and `dungeon`; pick the closest, but a typo here is harmless, + not a load error. ### `monsters.json` @@ -344,15 +456,26 @@ An object keyed by location key. Each entry is a building kind with a menu of ```json { "inn": {"kind": "inn", "name": "The Sleeping Drake", "glyph": "I", - "color": "town", "actions": ["rest", "leave"], + "color": "town", "actions": ["rest", "gamble", "leave"], "flavor": ["Lamplight pools on worn oak tables."]} } ``` -The verbs the engine understands are `rest` (inn), `buy`/`sell`/`forge` (shop), -`heal` (healer), `descend`/`challenge` (dungeon), and `leave`. (`quaff` is legal -anywhere and needs no menu entry.) Give each building the menu that matches its -role. +Give each building the menu that matches its role. The four building kinds and +the verbs the engine honours inside each are: + +| `kind` | actions the engine understands | +| --- | --- | +| `inn` | `rest`, `gamble`, `leave` | +| `shop` | `buy`, `sell`, `forge`, `leave` | +| `healer` | `heal`, `leave` | +| `dungeon` | `descend`, `challenge`, `leave` | + +`quaff` (drink a satchel tonic) is legal **anywhere** and needs no menu entry. +The `actions` list is advisory — it is the menu the narrator offers, NOT a +validated whitelist (see "What `validate` checks" below): a verb the engine does +not back simply confuses the narrator, so give each building only the verbs from +its row above. ### `events.json` @@ -375,10 +498,12 @@ 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. +The binding file: `name`, `width`, `height`, `spawn` `[x, y]` (the hero's start +cell, which must be on walkable terrain), 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 (each also on walkable +terrain), 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} @@ -462,11 +587,15 @@ a fully-forged piece is a multi-day saving. surfaces seldom, stats and rewards a clear notch above its tier, and remember it always drops `rare_drop_item` (a consumable) into the satchel. Keep rares OFF the first slot of any `dungeon_tiers` tier, or they would become a fixed rung -guardian instead of a rare roll. +guardian instead of a rare roll — `validate` now ENFORCES this, so a rare in a +dungeon tier's lead slot is a load error, not just bad form. Place the rare +anywhere after that tier's first 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 shop verbs are `buy`, `sell`, and `forge`. +**Location menus.** Give each building only the actions it can honour, drawn +from the per-kind table under `locations.json` above. An inn that offers `buy` +but no shop logic will confuse the narrator. This is the one major thing +`validate` does NOT check (see below): a wrong or invented verb loads fine and +only muddles the narration, so it is on you to match each menu to its building. --- @@ -480,4 +609,26 @@ 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`. + +### What `validate` checks, and what it cannot + +`validate` runs your pack through the very loader the server uses, so a pack +that validates will load and serve. But the loader checks *structure and +references*, not *meaning* — it cannot read your fiction. Keep the split honest: + +**`validate` DOES catch (a load error if wrong):** + +{{VALIDATE_COVERAGE}} + +**`validate` does NOT catch (the eyeball-only short list):** + +* **Location menu `actions` contents.** The list is the narrator's menu, not a + validated whitelist: a verb the engine does not back (a typo, or a fictional + `pray`) loads fine and only confuses the narration. Match each building's menu + to the per-kind table under `locations.json`. +* **Flavour and narration quality.** Names, `flavor` lines, event `text`, the + feel of the tier curve and the economy — the loader checks they are present + and in band, never whether they are *good*. That judgement is yours; the + `simulate` bot can tell you a world is winnable and sanely paced, but only you + can tell whether it is worth playing. """ diff --git a/examples/door-game/understone/engine/models.py b/examples/door-game/understone/engine/models.py index c83d3f7d..78d21dd8 100644 --- a/examples/door-game/understone/engine/models.py +++ b/examples/door-game/understone/engine/models.py @@ -204,3 +204,7 @@ class Settings: forge_base_cost: int forge_max_plus: int rare_drop_item: str + # v0.8 "worlds without authors": the Watch's per-world CRT palette. One of + # the names in WATCH_THEMES; defaults to "phosphor" (the original green), so + # a pack that omits it looks exactly as the Vale always has. + watch_theme: str = "phosphor" diff --git a/examples/door-game/understone/server.py b/examples/door-game/understone/server.py index 83082c0d..b4c76a04 100644 --- a/examples/door-game/understone/server.py +++ b/examples/door-game/understone/server.py @@ -20,6 +20,7 @@ Usage:: python -m understone # via module understone validate PATH # check a content pack loads cleanly understone newpack PATH # scaffold a new pack + authoring manual + understone worlds # list the bundled worlds and their soundness Environment variables --------------------- @@ -42,7 +43,7 @@ from typing import TYPE_CHECKING from mcp.server.fastmcp import FastMCP from starlette.responses import HTMLResponse, JSONResponse, Response -from understone import cli, watch +from understone import cli, sim, watch from understone.errors import WorldLoadError from understone.game import Game from understone.persistence import Store @@ -583,6 +584,17 @@ def _build_parser() -> argparse.ArgumentParser: 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") + sub.add_parser("worlds", help="list the bundled worlds and whether each is sound") + sim = sub.add_parser("simulate", help="run a greedy balance bot over a pack and report") + sim.add_argument("path", type=Path, help="the pack directory to simulate") + sim.add_argument("--days", type=int, default=30, help="sim-days to play (default 30)") + sim.add_argument("--seed", type=int, default=1, help="RNG seed (default 1)") + sim.add_argument( + "--seeds", + type=int, + default=None, + help="run a sweep of this many seeds from --seed and aggregate", + ) return parser @@ -598,4 +610,8 @@ def main(argv: list[str] | None = None) -> None: raise SystemExit(cli.cli_validate(args.path)) if args.cmd == "newpack": raise SystemExit(cli.cli_newpack(args.path)) + if args.cmd == "worlds": + raise SystemExit(cli.cli_worlds()) + if args.cmd == "simulate": + raise SystemExit(sim.cli_simulate(args.path, args.days, args.seed, seeds=args.seeds)) _serve() diff --git a/examples/door-game/understone/sim.py b/examples/door-game/understone/sim.py new file mode 100644 index 00000000..9f62e975 --- /dev/null +++ b/examples/door-game/understone/sim.py @@ -0,0 +1,960 @@ +"""The balance instrument — a greedy bot that PLAYS a world to measure it. + +This is a TUNING PROBE, not an optimiser. It drives a deliberately simple, +greedy heuristic adventurer through the *real* :class:`~understone.game.Game` +façade — the same ``join`` / ``move`` / ``action`` methods the MCP tools call — +over a fresh in-memory store, a seeded :class:`~understone.engine.rng.GameRNG`, +and an injected clock. Because it plays through the actual façade, a sim run is +also a fierce end-to-end integration test of the whole stack: every system the +report reflects (movement, the zone-banded forest, the rung ladder, the forge +and satchel, the Wyrm endgame) is exercised by the real engine, never mocked. + +The bot is a yardstick, not a player to admire. Its policy is the obvious greedy +one — spend each daily turn on the single best-looking action, navigate for free +between town and the wilds, keep itself geared and potioned — so the resulting +:class:`BalanceReport` answers "is this world *shaped* right for a competent but +unclever hero?": does a run make steady progress, is the fight/descend mix sane, +and — the load-bearing question — is the world *winnable*, i.e. can a greedy bot +actually slay the Wyrm in a reasonable number of days? + +The module is PURE: it imports the engine, the game façade, and the loader only, +and never touches ``mcp``, ``starlette``, or the network. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, TextIO + +from understone.engine import turns +from understone.engine.models import Item, Mode, Monster, Player, Slot +from understone.engine.rng import GameRNG +from understone.game import Game +from understone.persistence import Store +from understone.world.loader import load_world + +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path + + from understone.engine.world import World + +# The bot's adventurer name in every sim (a fixed handle keeps the run readable). +_BOT_NAME = "Probe" + +# A run begins at this fixed UTC instant; the clock advances exactly one day per +# simulated day so the daily turn budget resets cleanly between sim-days. Midday +# avoids the Watch day/night edges (irrelevant here, but keeps the instant tidy). +_SIM_START = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) + +# Heuristic margins (all fractions of max_hp). The bot rests/heals below +# ``_REST_BELOW``; it only commits to a fight or a rung it expects to END at or +# above ``_FIGHT_MARGIN`` / ``_DESCEND_MARGIN`` of full health, so it spends +# turns on bouts it can likely win rather than feeding the death-save. +_REST_BELOW = 0.5 +_FIGHT_MARGIN = 0.30 +_DESCEND_MARGIN = 0.45 +# The Wyrm is the climax and a legacy reset rides on it, so the bot will not +# stake a challenge until a pessimistic full-health bout clears this much hp — +# it keeps grinding the deep for levels, gear, and forge edges until it can +# actually win, rather than feeding doomed challenges to the boss. +_WYRM_MARGIN = 0.20 + +# "Flush" gold floor for opportunistic spending (gear, forge, potions): the bot +# keeps at least this much in reserve so a shopping spree never strands it unable +# to rest. A small, world-agnostic buffer expressed against a day's rest cost. +_RESERVE_RESTS = 3 + +# Hard cap on free navigation moves toward a single waypoint, so a bot that can +# never thread a monster-thick wood (or a malformed map) ends the day instead of +# looping forever. Generous — a clear path is a handful of 8-cell hops. +_GOTO_MAX_MOVES = 200 + + +@dataclass(frozen=True, slots=True) +class BalanceReport: + """The measured outcome of one greedy bot run over a world. + + Every field is a yardstick for tuning, not a score. ``deaths_survived`` are + the bouts the satchel's death-save snatched back; ``deaths_taken`` the + genuine spawn bounces. ``realized_fight_share`` is the fraction of + turn-spending actions that were forest fights (vs descents and the Wyrm + challenge) — the run's actual analogue of the pack's authored fight-weight. + ``ending_satchel`` is the potions still carried at the final bell. + + ``final_level`` is the level at the FINAL bell, not the peak reached: a Wyrm + kill reincarnates the hero with a legacy reset (``level`` → 1), so a winning + run can report a ``final_level`` as low as 1. Read it alongside + ``wyrm_killed`` — a low level on a won run means the hero is mid-reclimb, not + that the run stalled. Averaging ``final_level`` across a sweep that mixes wins + and losses conflates the two; the renders pair the two fields for this reason. + """ + + days: int + seed: int + final_level: int + total_gold_earned: int + deaths_survived: int + deaths_taken: int + rungs_cleared: int + wyrm_killed: bool + day_of_first_wyrm_kill: int | None + fights_fought: int + realized_fight_share: float + ending_satchel: tuple[str, ...] + + +class _Clock: + """A mutable injected clock: reports a fixed instant, steppable by a day.""" + + def __init__(self, start: datetime) -> None: + self._now = start + + def __call__(self) -> datetime: + return self._now + + def advance_day(self) -> None: + """Move the clock forward one UTC day (the sim-day boundary).""" + self._now += timedelta(days=1) + + +def simulate(pack_dir: Path, days: int, seed: int) -> BalanceReport: + """Play a greedy bot through the world at *pack_dir* and return its report. + + Builds a real :class:`Game` over a fresh in-memory store with the seeded RNG + and the steppable clock, joins the bot, then runs *days* sim-days: each day + the bot spends its turn budget on the best available action (with free + navigation between), and the clock advances one day so turns reset. The + returned :class:`BalanceReport` is fully determined by ``(pack_dir, days, + seed)`` — same inputs, identical report. + """ + world = load_world(pack_dir) + clock = _Clock(_SIM_START) + store = Store(":memory:") + game = Game(world, store, clock=clock, rng=GameRNG(seed=seed)) + bot = _Bot(game, world, clock) + bot.run(days) + return bot.report(days=days, seed=seed) + + +class _Bot: + """The greedy heuristic adventurer that drives the real game façade.""" + + def __init__(self, game: Game, world: World, clock: _Clock) -> None: + self.game = game + self.world = world + self.clock = clock + self.name = _BOT_NAME + self._walkable = _reachable(world) + self._waypoints = _named_waypoints(world) + self._hunt_spots = _zone_hunt_spots(world, self._walkable) + # Accumulators the report is built from. + self.total_gold_earned = 0 + self.deaths_survived = 0 + self.deaths_taken = 0 + self.fights_fought = 0 + self.descents = 0 + self.challenges = 0 + self.rungs_cleared = 0 + self.wins = 0 + self.day_of_first_wyrm_kill: int | None = None + + # -- the run loop ---------------------------------------------------- + + def run(self, days: int) -> None: + """Play *days* sim-days, advancing the clock one day between each.""" + self.game.join(self.name) + for _day in range(days): + self._play_day() + self.clock.advance_day() + + def _play_day(self) -> None: + """Spend the day's turn budget on the best action the bot can find. + + Movement and town errands (rest/heal/buy/forge) are free, so a single + day may interleave many of them around the few turn-spending bouts. The + loop ends when the bot is out of turns or can find nothing useful to do. + """ + # Roll the daily budget eagerly so the loop gate below sees the fresh + # turn count. The game does this lazily on the first action; the bot's + # gate reads turns_left BEFORE acting, so it must roll first itself. The + # roll is idempotent — the next façade action re-rolls the same ordinal + # and persists it — so this never double-grants. + turns.ensure_day(self._player(), self.clock, self._turn_budget()) + # Iteration cap: a pure runaway guard, never reached in normal play. Each + # loop either spends a turn or does a free errand (recover/buy/forge), + # and the free errands per day are bounded (gear is finite, forge and + # satchel cap out), so the real per-day count is well under this — a + # generous multiple of the turn budget that a healthy day never nears. + guard = 0 + max_steps = self._turn_budget() * 8 + 32 + while self._turns_left() > 0 and guard < max_steps: + guard += 1 + if not self._take_best_action(): + break + + def _take_best_action(self) -> bool: + """Do the single best thing right now; return False when nothing helps. + + Priority order (the greedy policy): survive (rest/heal when hurt), keep + the satchel and the gear strong while flush, then spend a turn on the + best bout — challenge the Wyrm if ready, else descend a rung the bot can + likely clear, else hunt the best survivable forest zone. + """ + player = self._player() + # 1. Survive: mend before risking a turn, if a bed/shrine is affordable. + if player.hp < player.max_hp * _REST_BELOW and self._recover(): + return True + # 2. Spend down a flush purse on lasting advantages (free, no turn). + if self._upgrade_gear() or self._stock_satchel() or self._forge_edge(): + return True + # 3. The Wyrm: the win condition, the moment it is reachable. + if self._wyrm_ready(): + self._heal_full_if_possible() + return self._challenge() + # 4. Descend a rung the bot expects to clear (mended first). + if self._should_descend(): + self._heal_full_if_possible() + return self._descend() + # 5. Otherwise hunt the best survivable forest for XP and gold. + return self._hunt() + + # -- decisions: recovery & spending ---------------------------------- + + def _recover(self) -> bool: + """Rest at the inn (preferred) or heal at the shrine; True if mended.""" + settings = self.world.settings + player = self._player() + if "inn" in self._waypoints and player.gold >= settings.rest_cost: + return self._errand("inn", "rest") + free_heal = settings.heal_cost_per_hp == 0 + if "healer" in self._waypoints and ( + free_heal or player.gold >= settings.heal_cost_per_hp * 4 + ): + return self._errand("healer", "heal") + return False + + def _upgrade_gear(self) -> bool: + """Buy the best affordable weapon/armour upgrade if flush; True if bought.""" + if not self._shop_offers("buy"): + return False + player = self._player() + weapon = self._best_upgrade(Slot.WEAPON, player.weapon_id) + armor = self._best_upgrade(Slot.ARMOR, player.armor_id) + target = self._dearer(weapon, armor) + if target is None or not self._can_afford(target.price): + return False + return self._errand("shop", "buy", item=target.item_id) + + def _stock_satchel(self) -> bool: + """Top the satchel up with the strongest affordable potion, if flush.""" + if not self._shop_offers("buy"): + return False + player = self._player() + carried = [c for c in player.satchel.split(",") if c] + if len(carried) >= self.world.settings.satchel_max: + return False + potion = self._best_affordable_potion() + if potion is None: + return False + return self._errand("shop", "buy", item=potion.item_id) + + def _forge_edge(self) -> bool: + """Forge a +1 edge on weapon then armour when gold is plentiful. + + Only fires once the bot is genuinely flush (a forge is the late-game + gold sink), and only when its gear is already the best the shop sells, + so it never forges a blade it is about to replace. + """ + if not self._shop_offers("forge"): + return False + settings = self.world.settings + player = self._player() + if not self._gear_is_best(): + return False + for current, slot_arg in ( + (player.weapon_plus, "weapon"), + (player.armor_plus, "armour"), + ): + if current >= settings.forge_max_plus: + continue + cost = settings.forge_base_cost * (current + 1) + if not self._can_afford(cost): + continue + return self._errand("shop", "forge", target=slot_arg) + return False + + # -- decisions: the turn-spending bouts ------------------------------ + + def _wyrm_ready(self) -> bool: + """True when the bot can BEAT the Wyrm: both gates plus a winnable bout. + + Meeting the engine's gates (level floor and full depth) only OPENS the + challenge; the bot adds its own readiness test — a pessimistic + full-health bout against the boss clearing :data:`_WYRM_MARGIN` — so it + challenges when it can win, not the instant it is allowed to. Until then + the deep-zone grind keeps raising its level, gear, and forge edges. + """ + settings = self.world.settings + player = self._player() + if "dungeon" not in self._waypoints: + return False + if player.level < settings.wyrm_min_level: + return False + if player.deepest_rung < len(settings.dungeon_tiers): + return False + boss = self.world.monster_by_id(settings.boss_monster) + if boss is None: + return False + return _survivable( + player.max_hp, player.max_hp, player.atk, player.def_, boss, _WYRM_MARGIN + ) + + def _challenge(self) -> bool: + """Enter the dungeon and challenge the Wyrm (spends a turn). + + Returns False if the dungeon menu can't be entered (malformed map), so + the day loop ends rather than spinning on a no-op overworld challenge. + """ + if not self._enter("dungeon"): + return False + wins_before = self._player().wins + out = self._act("challenge") + self.challenges += 1 + self._note_outcome(out, wins_before) + self._leave_if_in_menu() + return True + + def _should_descend(self) -> bool: + """True when a dungeon exists, a rung remains, and the bot can likely win. + + Looks at the SPECIFIC next-rung guardian (``band[0]`` of the next tier, + the engine's fixed-foe rung) and only commits if a full-health bout + projects to end above the descend margin — so the bot pushes the deep + when geared for it rather than throwing turns at a wall. + """ + settings = self.world.settings + player = self._player() + if "dungeon" not in self._waypoints: + return False + if player.deepest_rung >= len(settings.dungeon_tiers): + return False + guardian = self._rung_guardian(player.deepest_rung) + if guardian is None: + return False + # Judged from FULL health (the bot heals before descending), so the gate + # asks "can I clear this rung fresh?" not "from my current scratches?". + return _survivable( + player.max_hp, player.max_hp, player.atk, player.def_, guardian, _DESCEND_MARGIN + ) + + def _descend(self) -> bool: + """Enter the dungeon and descend one rung (spends a turn). + + Returns False if the dungeon menu can't be entered, so a malformed map + ends the day instead of spinning. ``advanced`` (a cleared rung) suppresses + the bounce check, since a cleared rung climbs out rather than bouncing. + """ + if not self._enter("dungeon"): + return False + before = self._player().deepest_rung + wins_before = self._player().wins + out = self._act("descend") + self.descents += 1 + after = self._player().deepest_rung + self.rungs_cleared = max(self.rungs_cleared, after) + self._note_outcome(out, wins_before, advanced=after > before) + self._leave_if_in_menu() + return True + + def _hunt(self) -> bool: + """Fight in the best survivable forest zone; True if a bout was fought. + + Returns False when there is no reachable zone the bot can survive, OR + when the walk to the spot ended in a location MENU (a door swallowed the + navigation) — in either case the day yields the loop's turn rather than + burning it. A genuine bout always spends a turn, so a True return makes + real progress toward the daily cap. + """ + spot = self._best_hunt_spot() + if spot is None: + return False + self._goto_xy(spot) + if self._player().mode is not Mode.TILE: + # A door swallowed the walk; step back out and try once more. + self._leave_if_in_menu() + self._goto_xy(spot) + # Authoritative pre-check: a "fight" issued from a MENU is REJECTED by the + # engine without spending a turn (it is not a tile action), so counting it + # as a fought bout would over-count and let the day loop spin to its cap. + # If still not on open ground, yield the turn — leave the menu and bail. + if self._player().mode is not Mode.TILE: + self._leave_if_in_menu() + return False + wins_before = self._player().wins + out = self._act("fight") + if "nothing stirs to fight here" in out: + # Standing in TILE mode but outside any zone (navigation fell short): + # the engine spent no turn, so this is not a fought bout. Give up for + # now rather than re-rolling the same dry cell forever. + return False + self.fights_fought += 1 + self._note_outcome(out, wins_before) + return True + + # -- outcome bookkeeping --------------------------------------------- + + def _note_outcome(self, out: str, wins_before: int, *, advanced: bool = False) -> None: + """Fold one bout's result into the accumulators from observable state. + + A Wyrm kill shows as ``wins`` ticking up (the legacy reset bumps it); a + death-save shows as the engine's death-save line in *out*; a genuine + bounce shows as the hero standing at the spawn at 1 HP without either of + the above. ``advanced`` (descend only) suppresses the bounce check on a + cleared rung, which never bounces. Gold earned is tracked separately, + per call, in :meth:`_act`. + """ + player = self._player() + if player.wins > wins_before: + self.wins += player.wins - wins_before + if self.day_of_first_wyrm_kill is None: + self.day_of_first_wyrm_kill = self._current_day + return + if Game._DEATH_SAVE_LINE in out: + self.deaths_survived += 1 + return + if not advanced and (player.x, player.y) == self.world.spawn and player.hp <= 1: + self.deaths_taken += 1 + + # -- low-level game driving ------------------------------------------ + + def _act(self, action: str, *, target: str = "", item: str = "") -> str: + """Call ``game.action`` and accrue any positive gold delta as earnings. + + Earnings are POSITIVE inflows only (a fight reward, a forest gold find, + a dice win), so spending at the shop/inn/forge never counts against the + total. The before/after read brackets the single façade call, so every + gold source the engine applies is captured without enumerating them. + """ + before = self._player().gold + out = self.game.action(self.name, action, target, item) + delta = self._player().gold - before + if delta > 0: + self.total_gold_earned += delta + return out + + def _goto(self, waypoint: str) -> None: + """Navigate to a named waypoint (inn/shop/healer/dungeon door cell).""" + target = self._waypoints.get(waypoint) + if target is not None: + self._goto_xy(target) + + def _enter(self, waypoint: str) -> bool: + """Ensure the bot stands INSIDE *waypoint*'s menu; return success. + + A location's menu opens only by MOVING onto its door — standing on the + door cell in TILE mode (e.g. right after leaving) does not reopen it. So + this walks to a cell ADJACENT to the door and steps in, guaranteeing the + ``entered_location`` flip to MENU mode. Already in the right menu, it is a + no-op. This is the entry every town errand uses, so a buy/rest/forge is + never attempted from the overworld (which would silently no-op and spin). + """ + door = self._waypoints.get(waypoint) + if door is None: + return False + player = self._player() + if player.mode is Mode.MENU and player.at_location == waypoint: + return True + self._leave_if_in_menu() + approach = _adjacent_open(self.world, self._walkable, door) + if approach is None: + return False + self._goto_xy(approach) + if (self._player().x, self._player().y) != approach: + return False + step = _step_between(approach, door) + if step is None: + return False + self.game.move(self.name, step, "", 0) + p = self._player() + return p.mode is Mode.MENU and p.at_location == waypoint + + def _errand(self, waypoint: str, action: str, *, target: str = "", item: str = "") -> bool: + """Enter *waypoint*'s menu and run one free town *action*; True if done. + + Returns False when the menu can't be entered (a malformed map), so the + caller treats the errand as "couldn't help" rather than spinning on a + no-op the way a TILE-mode buy would. The menu is left afterwards so the + next decision starts cleanly on the overworld. + """ + if not self._enter(waypoint): + return False + self._act(action, target=target, item=item) + self._leave_if_in_menu() + return True + + def _goto_xy(self, goal: tuple[int, int]) -> None: + """Walk the bot to *goal* over free moves, threading incidental foes. + + Steps the BFS path in 8-cell hops, re-planning from the actual position + after each hop because a walk can stop early (a wall, a door, or a + wandering monster). Incidental forest encounters need no handling: a + blocked move simply makes no progress that hop, and the next hop re-rolls + from the new cell, so the bot threads the wood without spending a turn. + A move-count cap prevents an unthreadable map from looping forever. + """ + moves = 0 + while moves < _GOTO_MAX_MOVES: + player = self._player() + if player.mode is Mode.MENU: + # Already at a door; if it is the goal door, we're there. + loc = self.world.location_at(*goal) + if loc is not None and (player.x, player.y) == goal: + return + self._leave_if_in_menu() + player = self._player() + if (player.x, player.y) == goal: + return + path = _bfs_step_path(self.world, self._walkable, (player.x, player.y), goal) + if not path: + return + steps = "".join(path[:8]) + self.game.move(self.name, steps, "", 0) + moves += 1 + + def _leave_if_in_menu(self) -> None: + """Step back onto the overworld if the bot is inside a location menu.""" + if self._player().mode is Mode.MENU: + self.game.action(self.name, "leave", "", "") + + def _heal_full_if_possible(self) -> None: + """Rest/heal to full before a marquee bout, if at all affordable.""" + if self._player().hp < self._player().max_hp: + self._recover() + + # -- heuristics over world content ----------------------------------- + + def _best_hunt_spot(self) -> tuple[int, int] | None: + """Pick the highest-tier forest zone whose toughest foe the bot survives. + + Escalates the bot from the starter wood to deeper zones as its gear and + level grow: it scans zones high tier first and returns the first whose + toughest COMMON foe a full-health bout clears above the fight margin. + When nothing yet qualifies (a fresh, under-geared bot), it falls back to + the LOWEST-tier zone — the starter wood — so the bot always has the + gentlest available ground to grind on rather than throwing itself at the + deep. The death-save covers the occasional unlucky bout there. + """ + player = self._player() + if not self._hunt_spots: + return None + ranked = sorted(self._hunt_spots, key=lambda zs: zs[0], reverse=True) + for _tier_hi, spot, toughest in ranked: + if toughest is None: + continue + # Full-health yardstick: the bot heals below _REST_BELOW, so "can I + # win this zone's toughest common foe fresh?" is the right question. + if _survivable( + player.max_hp, player.max_hp, player.atk, player.def_, toughest, _FIGHT_MARGIN + ): + return spot + # Nothing clears the margin yet: grind the gentlest (lowest-tier) zone. + return ranked[-1][1] + + def _rung_guardian(self, rung_index: int) -> Monster | None: + """Return the fixed guardian of the next rung (``band[0]`` of its tier).""" + tiers = self.world.settings.dungeon_tiers + if not 0 <= rung_index < len(tiers): + return None + band = self.world.monsters_for_tier_band(tiers[rung_index], tiers[rung_index]) + return band[0] if band else None + + def _best_upgrade(self, slot: Slot, equipped_id: str) -> Item | None: + """Return the best purchasable *slot* item that beats the equipped one. + + Compares by the slot's base combat bonus (weapon→atk, armour→def): the + dearest shop item whose bonus exceeds the equipped item's. A None result + means nothing in the shop improves on what the bot wears. + """ + equipped = self.world.item_by_id(equipped_id) + equipped_bonus = self._slot_stat(equipped, slot) if equipped else 0 + best: Item | None = None + for item in self.world.items: + if item.slot is not slot or item.price <= 0: + continue + if self._slot_stat(item, slot) <= equipped_bonus: + continue + if best is None or item.price > best.price: + best = item + return best + + def _best_affordable_potion(self) -> Item | None: + """Return the strongest-heal consumable the bot can currently afford.""" + best: Item | None = None + for item in self.world.items: + if item.slot is not Slot.CONSUMABLE or item.price <= 0: + continue + if not self._can_afford(item.price): + continue + if best is None or item.heal > best.heal: + best = item + return best + + def _shop_offers(self, verb: str) -> bool: + """True when a shop exists AND its menu actually exposes *verb*. + + The bot drives an arbitrary authored pack, whose shop need not list every + verb the Vale's does: a pack may sell wares but offer no forge, say. The + engine rejects a verb absent from a location's ``actions`` WITHOUT + spending a turn or coin, and :meth:`_errand` cannot tell that no-op from a + real one — so it would report success and the day loop would spin. Gating + the shop errands on the verb being genuinely on offer closes that spin. + """ + if "shop" not in self._waypoints: + return False + loc = self.world.location_by_key("shop") + return loc is not None and verb in loc.actions + + def _gear_is_best(self) -> bool: + """True when both equipped slots are already the shop's strongest.""" + player = self._player() + return ( + self._best_upgrade(Slot.WEAPON, player.weapon_id) is None + and self._best_upgrade(Slot.ARMOR, player.armor_id) is None + ) + + def _can_afford(self, price: int) -> bool: + """True when paying *price* still leaves the rest-cost reserve intact.""" + reserve = self.world.settings.rest_cost * _RESERVE_RESTS + return self._player().gold - price >= reserve + + @staticmethod + def _slot_stat(item: Item, slot: Slot) -> int: + return item.atk if slot is Slot.WEAPON else item.def_ + + @staticmethod + def _dearer(a: Item | None, b: Item | None) -> Item | None: + """Return whichever upgrade is the dearer (a rough 'bigger jump') pick.""" + if a is None: + return b + if b is None: + return a + return a if a.price >= b.price else b + + # -- report ---------------------------------------------------------- + + def report(self, *, days: int, seed: int) -> BalanceReport: + """Freeze the run's accumulators into a :class:`BalanceReport`.""" + player = self._player() + turn_actions = self.fights_fought + self.descents + self.challenges + fight_share = self.fights_fought / turn_actions if turn_actions else 0.0 + satchel = tuple(c for c in player.satchel.split(",") if c) + return BalanceReport( + days=days, + seed=seed, + final_level=player.level, + total_gold_earned=self.total_gold_earned, + deaths_survived=self.deaths_survived, + deaths_taken=self.deaths_taken, + rungs_cleared=self.rungs_cleared, + wyrm_killed=self.wins > 0, + day_of_first_wyrm_kill=self.day_of_first_wyrm_kill, + fights_fought=self.fights_fought, + realized_fight_share=fight_share, + ending_satchel=satchel, + ) + + # -- tiny accessors -------------------------------------------------- + + @property + def _current_day(self) -> int: + """The 1-indexed sim-day the clock currently sits on.""" + return (self.clock().date() - _SIM_START.date()).days + 1 + + def _player(self) -> Player: + return self.game.players[self.name] + + def _turns_left(self) -> int: + return self._player().turns_left + + def _turn_budget(self) -> int: + return self.world.settings.daily_turns + + +# --------------------------------------------------------------------------- +# Combat & navigation helpers (pure functions over world content) +# --------------------------------------------------------------------------- + + +def _survivable( + cur_hp: int, max_hp: int, atk: int, def_: int, monster: Monster, margin: float +) -> bool: + """Project whether a bout from *cur_hp* against *monster* clears *margin*. + + A PESSIMISTIC estimate: the engine jitters each blow by ``randint(-1, 2)`` + (:func:`understone.engine.combat._swing`), so this assumes the player's blows + land at the low end (``-1``) and the monster's at the high end (``+2``), + player striking first. The surviving hp is expressed as a fraction of + ``max_hp`` and compared to *margin*. Because the real bout is usually kinder + AND the satchel death-save backstops a bad one, a margin-clearing bout is a + safe turn to spend; the gate errs toward over-preparing, which is what a + careful greedy probe should do. + """ + player_dmg = max(1, (atk - 1) - monster.def_) + monster_dmg = max(1, (monster.atk + 2) - def_) + rounds_to_kill = -(-monster.hp // player_dmg) # ceil division + # The player strikes first, so they take one fewer hit than rounds-to-kill. + hits_taken = rounds_to_kill - 1 + remaining = cur_hp - hits_taken * monster_dmg + return remaining >= max_hp * margin + + +def _reachable(world: World) -> set[tuple[int, int]]: + """Return every walkable cell reachable from the spawn (a BFS flood). + + Computed once per run so navigation never re-floods. Location doors count as + walkable, so the town buildings and the dungeon mouth are in the set. + """ + seen = {world.spawn} + frontier: deque[tuple[int, int]] = deque([world.spawn]) + while frontier: + x, y = frontier.popleft() + for dx, dy in ((0, -1), (0, 1), (1, 0), (-1, 0)): + nxt = (x + dx, y + dy) + if nxt not in seen and world.is_walkable(*nxt): + seen.add(nxt) + frontier.append(nxt) + return seen + + +def _bfs_step_path( + world: World, + walkable: set[tuple[int, int]], + start: tuple[int, int], + goal: tuple[int, int], +) -> list[str]: + """Return cardinal steps (N/S/E/W) along a shortest walkable path to *goal*. + + A plain breadth-first search over the precomputed *walkable* set; returns the + step letters the game's ``move`` accepts, or an empty list when *goal* is + unreachable (it never is, for a bundled world's town and dungeon, but the + bot tolerates a malformed pack gracefully). The goal cell itself need not be + in *walkable* beyond being a location door, which the flood already included. + """ + if start == goal: + return [] + came_from: dict[tuple[int, int], tuple[tuple[int, int], str]] = {} + frontier: deque[tuple[int, int]] = deque([start]) + seen = {start} + deltas = (((0, -1), "N"), ((0, 1), "S"), ((1, 0), "E"), ((-1, 0), "W")) + while frontier: + cur = frontier.popleft() + if cur == goal: + return _reconstruct(came_from, start, goal) + cx, cy = cur + for (dx, dy), letter in deltas: + nxt = (cx + dx, cy + dy) + if nxt in seen or nxt not in walkable: + continue + seen.add(nxt) + came_from[nxt] = (cur, letter) + frontier.append(nxt) + return [] + + +def _reconstruct( + came_from: dict[tuple[int, int], tuple[tuple[int, int], str]], + start: tuple[int, int], + goal: tuple[int, int], +) -> list[str]: + """Walk the BFS parent links back from *goal* to *start* into step letters.""" + steps: list[str] = [] + node = goal + while node != start: + prev, letter = came_from[node] + steps.append(letter) + node = prev + steps.reverse() + return steps + + +def _named_waypoints(world: World) -> dict[str, tuple[int, int]]: + """Map each location KEY (inn/shop/healer/dungeon) to its door cell.""" + return {loc.key: (loc.x, loc.y) for loc in world.locations} + + +_STEP_DELTAS: dict[tuple[int, int], str] = {(0, -1): "N", (0, 1): "S", (1, 0): "E", (-1, 0): "W"} + + +def _adjacent_open( + world: World, walkable: set[tuple[int, int]], door: tuple[int, int] +) -> tuple[int, int] | None: + """Return a walkable, non-location cell orthogonally adjacent to *door*. + + The cell the bot stands on to then step INTO the door (opening its menu). It + must itself not be another location door, or stepping would enter the wrong + building. ``None`` only for a door walled in on all four sides, which a + bundled world never has. + """ + dx, dy = door + for ox, oy in ((0, -1), (0, 1), (1, 0), (-1, 0)): + cell = (dx + ox, dy + oy) + if cell in walkable and world.location_at(*cell) is None: + return cell + return None + + +def _step_between(start: tuple[int, int], goal: tuple[int, int]) -> str | None: + """Return the single cardinal step from *start* to an adjacent *goal*.""" + return _STEP_DELTAS.get((goal[0] - start[0], goal[1] - start[1])) + + +def _zone_hunt_spots( + world: World, walkable: set[tuple[int, int]] +) -> list[tuple[int, tuple[int, int], Monster | None]]: + """Return one huntable spot per zone: ``(tier_hi, cell, toughest_foe)``. + + For each zone, the nearest-to-spawn reachable cell inside it (so the bot can + actually stand there and fight) plus the toughest COMMON (non-rare) foe in + the zone's band — the survivability yardstick. Rares are excluded from that + yardstick: they carry a low weight (they surface seldom) and the death-save + covers an unlucky tough draw, so gating the whole zone on a rare it almost + never meets would freeze the bot in the starter wood. Zones with no reachable + cell or no fightable foe are dropped, so every spot is a real hunting ground. + """ + out: list[tuple[int, tuple[int, int], Monster | None]] = [] + for zone in world.zones: + cell = _nearest_in_zone(world, walkable, zone) + if cell is None: + continue + band = world.monsters_for_tier_band(zone.tier_lo, zone.tier_hi) + common = [m for m in band if not m.rare] or band + toughest = max(common, key=lambda m: m.hp + m.atk) if common else None + # A zone whose tier band holds no fightable foe is no hunting ground: a + # fight there only ever yields "nothing stirs". Drop it so the fallback + # in _best_hunt_spot (ranked[-1]) can never land the bot on a dead zone. + if toughest is None: + continue + out.append((zone.tier_hi, cell, toughest)) + return out + + +def _nearest_in_zone( + world: World, walkable: set[tuple[int, int]], zone: object +) -> tuple[int, int] | None: + """Return the reachable, non-door cell inside *zone* closest to the spawn. + + Location-door cells are walkable (``is_walkable`` returns True for a door), + but standing on one flips the bot into that location's MENU — useless ground + for a forest fight. So door cells are skipped here, mirroring the filter in + :func:`_adjacent_open`, leaving only true open ground the bot can fight on. + """ + sx, sy = world.spawn + best: tuple[int, int] | None = None + best_d = None + for x, y in walkable: + if not zone.contains(x, y): # type: ignore[attr-defined] + continue + if world.location_at(x, y) is not None: + continue + d = abs(x - sx) + abs(y - sy) + if best_d is None or d < best_d: + best_d = d + best = (x, y) + return best + + +# --------------------------------------------------------------------------- +# CLI rendering +# --------------------------------------------------------------------------- + + +def cli_simulate( + pack_dir: Path, + days: int, + seed: int, + out: TextIO | None = None, + seeds: int | None = None, +) -> int: + """Run the bot over *pack_dir* and print a readable balance report; return 0. + + With *seeds* unset (or 1) this runs a single seed and prints its full + report. With ``seeds=K`` it runs seeds ``seed .. seed+K-1`` and prints an + AGGREGATE: per-seed one-liners plus the mean and spread of the headline + measures across the sweep — the form an author reads to judge whether a + world is reliably winnable and sanely paced, not just lucky on one seed. + """ + import sys + + out = out if out is not None else sys.stdout + world = load_world(pack_dir) + count = seeds if seeds and seeds > 1 else 1 + reports = [simulate(pack_dir, days, seed + i) for i in range(count)] + if count == 1: + print(_render_report(world.name, reports[0]), file=out) + else: + print(_render_sweep(world.name, days, reports), file=out) + return 0 + + +def _render_report(world_name: str, r: BalanceReport) -> str: + """Render a single-seed report as an aligned, human-readable block.""" + wyrm = f"yes (first on day {r.day_of_first_wyrm_kill})" if r.wyrm_killed else "no" + satchel = ", ".join(r.ending_satchel) if r.ending_satchel else "(empty)" + lines = [ + f"{world_name} — greedy bot, {r.days} days, seed {r.seed}", + f" final level : {r.final_level} " + "(level at the final bell; a Wyrm kill resets to 1 — read with Wyrm slain)", + f" gold earned : {r.total_gold_earned}", + f" fights fought : {r.fights_fought}", + f" fight share : {r.realized_fight_share:.0%} of turn-actions", + f" rungs cleared : {r.rungs_cleared}", + f" death-saves : {r.deaths_survived} survived, {r.deaths_taken} taken", + f" Wyrm slain : {wyrm}", + f" ending satchel : {satchel}", + ] + return "\n".join(lines) + + +def _render_sweep(world_name: str, days: int, reports: list[BalanceReport]) -> str: + """Render a multi-seed sweep: per-seed lines, then means and spreads.""" + lines = [f"{world_name} — greedy bot sweep, {days} days, {len(reports)} seeds", ""] + for r in reports: + kill = f"day {r.day_of_first_wyrm_kill}" if r.wyrm_killed else "—" + lines.append( + f" seed {r.seed:>3}: Lv{r.final_level:<3} " + f"gold {r.total_gold_earned:>6} fights {r.fights_fought:>3} " + f"rungs {r.rungs_cleared} Wyrm {kill}" + ) + lines.append("") + kills = sum(1 for r in reports if r.wyrm_killed) + kill_days = [r.day_of_first_wyrm_kill for r in reports if r.day_of_first_wyrm_kill is not None] + lines.append(" aggregate (mean [min..max]):") + lines.append( + f" final level : {_stat_line(r.final_level for r in reports)} " + "(level at the final bell; a Wyrm kill resets to 1 — read with Wyrm slain)" + ) + lines.append(f" gold earned : {_stat_line(r.total_gold_earned for r in reports)}") + lines.append(f" fights fought : {_stat_line(r.fights_fought for r in reports)}") + lines.append(f" fight share : {_mean(r.realized_fight_share for r in reports):.0%}") + lines.append(f" rungs cleared : {_stat_line(r.rungs_cleared for r in reports)}") + lines.append(f" Wyrm slain : {kills}/{len(reports)} seeds") + if kill_days: + lines.append(f" first kill day: {_stat_line(iter(kill_days))}") + return "\n".join(lines) + + +def _stat_line(values: Iterable[int]) -> str: + """Render ``mean [min..max]`` for an integer measure across the sweep.""" + data = list(values) + if not data: + return "—" + return f"{sum(data) / len(data):.1f} [{min(data)}..{max(data)}]" + + +def _mean(values: Iterable[float]) -> float: + """Return the arithmetic mean of *values* (0.0 when empty).""" + data = list(values) + return sum(data) / len(data) if data else 0.0 diff --git a/examples/door-game/understone/watch.py b/examples/door-game/understone/watch.py index a6b50be2..b726f06f 100644 --- a/examples/door-game/understone/watch.py +++ b/examples/door-game/understone/watch.py @@ -42,6 +42,10 @@ def build_world_payload(world: World) -> dict[str, object]: them as an overlay). ``legend`` maps every terrain glyph that appears to a palette colour *name*; the client owns the name→hex mapping. Completeness is a contract: every glyph in ``glyph_rows`` has a ``legend`` entry. + + ``theme`` is the pack's Watch CRT palette (``settings.watch_theme``); the + page looks it up in its own JS THEME table on fetch and swaps the CSS + custom-property values, so each world has its own phosphor colour. """ glyph_rows: list[str] = [] legend: dict[str, str] = {} @@ -67,6 +71,7 @@ def build_world_payload(world: World) -> dict[str, object]: "name": world.name, "width": world.width, "height": world.height, + "theme": world.settings.watch_theme, "glyph_rows": glyph_rows, "legend": legend, "locations": locations, @@ -318,6 +323,63 @@ _WATCH_HTML_TEMPLATE = """\ (function () { "use strict"; + // Per-world CRT palette. Each named theme is a set of CSS custom-property + // values applied to :root when world.json arrives (the pack's + // settings.watch_theme picks one). "phosphor" holds the EXACT values of the + // :root block above, so the default Vale is pixel-for-pixel unchanged; the + // others re-tint the whole console: + // phosphor — the original green CRT (default). + // amber — a warm gold CRT (classic amber monochrome monitor). + // ice — a pale, cold blue CRT. + // ember — a hot red/orange CRT. + // The day/night wash from v0.6 composes ON TOP of whichever theme is set. + var THEMES = { + phosphor: { + "--phosphor": "#7dffa0", + "--phosphor-dim": "#2f7a46", + "--amber": "#ffb44d", + "--bg": "#050a06", + "--panel": "#0a140d", + "--edge": "#163a22" + }, + amber: { + "--phosphor": "#ffc14d", + "--phosphor-dim": "#7a5320", + "--amber": "#fff0a8", + "--bg": "#0a0702", + "--panel": "#14100a", + "--edge": "#3a2c16" + }, + ice: { + "--phosphor": "#9fe6ff", + "--phosphor-dim": "#2f5f7a", + "--amber": "#ffe07d", + "--bg": "#04080a", + "--panel": "#0a1014", + "--edge": "#16303a" + }, + ember: { + "--phosphor": "#ff8a6b", + "--phosphor-dim": "#7a3320", + "--amber": "#ffd07d", + "--bg": "#0a0503", + "--panel": "#140a07", + "--edge": "#3a1c16" + } + }; + + // Swap the CSS custom-property values for the pack's theme. Unknown or + // missing theme names fall back to "phosphor", so the console always has a + // coherent palette even if a future theme reaches the page unknown. + function applyTheme(name) { + var theme = THEMES[name] || THEMES.phosphor; + for (var prop in theme) { + if (Object.prototype.hasOwnProperty.call(theme, prop)) { + document.documentElement.style.setProperty(prop, theme[prop]); + } + } + } + // Palette colour-name -> phosphor-tinted hex. Mirrors understone.screen.palette // Color values; the base map is coloured from this, never from the server. var PALETTE = { @@ -376,6 +438,7 @@ _WATCH_HTML_TEMPLATE = """\ // Paint the base map ONCE. Each row is a sequence of runs, a new run // only where the legend colour changes, so a row is a handful of spans. function paintMap(world) { + applyTheme(world.theme); document.getElementById("world-name").textContent = world.name + " — Live Watch"; var legend = world.legend || {}; var rows = world.glyph_rows || []; diff --git a/examples/door-game/understone/world/__init__.py b/examples/door-game/understone/world/__init__.py index 92159bd6..96a8825b 100644 --- a/examples/door-game/understone/world/__init__.py +++ b/examples/door-game/understone/world/__init__.py @@ -1,8 +1,41 @@ """Content-pack loading — JSON on disk becomes a runtime ``World``.""" +from __future__ import annotations + 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" + +# Zero-or-more bundled ALTERNATE worlds live one directory deeper, each in its +# own ``/`` subdirectory carrying a ``world.json``. The Vale is special +# (it is the default and lives at ``data/``); alternates are discovered here. +PACKS_DIR = Path(__file__).resolve().parent / "packs" + +# The reserved slug of the default Vale — it is never a packs/ subdirectory but +# is always listed first by the discovery helper below. +VALE_SLUG = "vale" + + +def bundled_world_dirs() -> list[tuple[str, Path]]: + """Return every bundled world as ``(slug, directory)``, the Vale first. + + The default Vale (slug :data:`VALE_SLUG`, the ``data/`` directory) always + leads; the alternates follow in slug-alphabetical order. An alternate is + any immediate subdirectory of :data:`PACKS_DIR` that contains a + ``world.json`` — non-pack files (the README placeholder) and directories + without a world file are skipped, so the list is exactly the loadable + worlds. This is the single discovery path the ``worlds`` listing and any + future world resolver share. + """ + found: list[tuple[str, Path]] = [(VALE_SLUG, PACKAGED_WORLD_DIR)] + if PACKS_DIR.is_dir(): + alternates = [ + (entry.name, entry) + for entry in PACKS_DIR.iterdir() + if entry.is_dir() and (entry / "world.json").is_file() + ] + found.extend(sorted(alternates, key=lambda pair: pair[0])) + return found diff --git a/examples/door-game/understone/world/data/world.json b/examples/door-game/understone/world/data/world.json index 328d802b..ba70ff3e 100644 --- a/examples/door-game/understone/world/data/world.json +++ b/examples/door-game/understone/world/data/world.json @@ -126,6 +126,7 @@ "satchel_max": 3, "forge_base_cost": 60, "forge_max_plus": 3, - "rare_drop_item": "greater_potion" + "rare_drop_item": "greater_potion", + "watch_theme": "phosphor" } } diff --git a/examples/door-game/understone/world/loader.py b/examples/door-game/understone/world/loader.py index 4238ae60..f009baee 100644 --- a/examples/door-game/understone/world/loader.py +++ b/examples/door-game/understone/world/loader.py @@ -67,6 +67,13 @@ EVENT_AMOUNT_BANDS: dict[str, tuple[int, int]] = { } _EVENT_KINDS = frozenset({"fight", "gold", "heal", "trap", "lore"}) +# The legal Watch CRT palettes a pack may choose via ``settings.watch_theme``. +# "phosphor" is the original green and the default; the Watch's JS THEME table +# (understone.watch) carries the matching CSS custom-property values for each. +# This is the loader band for watch_theme — an unknown name is a load error. +WATCH_THEMES = frozenset({"phosphor", "amber", "ice", "ember"}) +DEFAULT_WATCH_THEME = "phosphor" + # 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. @@ -238,6 +245,13 @@ def _load_monsters(root: Path) -> list[Monster]: ) if not out: raise WorldLoadError("monsters.json defines no monsters") + bosses = [m for m in out if m.boss] + if len(bosses) > 1: + names = ", ".join(repr(m.name) for m in bosses) + raise WorldLoadError( + f'monsters.json flags {len(bosses)} monsters as "boss": true ({names}); ' + "a world has exactly one boss — the single endgame foe settings.boss_monster names" + ) return out @@ -603,6 +617,7 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons dungeon_tiers = _decode_dungeon_tiers(spec, monsters) boss_monster = _decode_boss_monster(spec, monsters) rare_drop_item = _decode_rare_drop_item(spec, items) + watch_theme = _decode_watch_theme(spec) return Settings( daily_turns=values["daily_turns"], @@ -632,6 +647,7 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons forge_base_cost=values["forge_base_cost"], forge_max_plus=values["forge_max_plus"], rare_drop_item=rare_drop_item, + watch_theme=watch_theme, ) @@ -678,6 +694,26 @@ def _decode_rare_drop_item(spec: dict[str, Any], items: list[Item]) -> str: return drop_id +def _decode_watch_theme(spec: dict[str, Any]) -> str: + """Resolve and validate the Watch CRT palette name (optional, defaulted). + + ``watch_theme`` is OPTIONAL: a pack that omits it keeps the original + :data:`DEFAULT_WATCH_THEME` ("phosphor"), so no author is forced to set it + and an existing pack is unchanged. When present it must name one of + :data:`WATCH_THEMES`; an unknown palette is a load error naming the legal + set, since the Watch's JS would have no variables to apply for it. + """ + raw = spec.get("watch_theme", DEFAULT_WATCH_THEME) + theme = str(raw) + if theme not in WATCH_THEMES: + legal = ", ".join(sorted(WATCH_THEMES)) + raise WorldLoadError( + f"world.json settings.watch_theme = {theme!r} is not a known theme; " + f"choose one of: {legal}" + ) + return theme + + def _decode_dungeon_tiers(spec: dict[str, Any], monsters: list[Monster]) -> tuple[int, ...]: """Parse the ordered dungeon-gauntlet tier ladder, one foe per tier. @@ -685,6 +721,13 @@ def _decode_dungeon_tiers(spec: dict[str, Any], monsters: list[Monster]) -> tupl the gauntlet would silently skip that rung: the gauntlet draws from ``monsters_for_tier_band``, which excludes boss monsters, so a boss-only tier loads cleanly yet has no fightable foe at runtime. + + Each tier's *first* non-boss monster in file order is its fixed rung + guardian (``monsters_for_tier_band(t, t)[0]``, the engine's deterministic + pick), so that monster must NOT be ``rare``: a rare in the lead slot of a + dungeon tier would be promoted to a fixed, repeatable guardian and pulled + out of the weighted rare pool entirely. The rule is checked here, where the + tier ladder and the monster order meet. """ raw_tiers = _require(spec, "dungeon_tiers", "world.json settings") if not (isinstance(raw_tiers, list) and raw_tiers): @@ -695,7 +738,16 @@ def _decode_dungeon_tiers(spec: dict[str, Any], monsters: list[Monster]) -> tupl tier = int(value) if tier not in available: raise WorldLoadError( - f"world.json settings.dungeon_tiers[{i}] = {tier} has no monster in the pack" + f"world.json settings.dungeon_tiers[{i}] = {tier} has no non-boss monster " + "in the pack (the dungeon gauntlet excludes the boss, so a boss-only tier " + "leaves the rung unfillable)" + ) + guardian = next(m for m in monsters if m.tier == tier and not m.boss) + if guardian.rare: + raise WorldLoadError( + f"monsters.json: {guardian.name!r} is rare but is the first tier-{tier} " + f"monster, so it would become the fixed guardian of dungeon rung tier {tier} " + "— put a non-rare monster first in that tier" ) tiers.append(tier) return tuple(tiers) diff --git a/examples/door-game/understone/world/packs/README.md b/examples/door-game/understone/world/packs/README.md new file mode 100644 index 00000000..3887a6cf --- /dev/null +++ b/examples/door-game/understone/world/packs/README.md @@ -0,0 +1,20 @@ +# Bundled alternate worlds + +This directory holds **bundled alternate worlds** — zero or more content packs +that ship with Understone alongside the default Vale of Understone (which lives +one level up, in `../data/`). + +Each alternate is its own subdirectory containing a `world.json` (and the rest +of the pack's JSON files). The slug is the subdirectory name. `understone +worlds` discovers the Vale plus every pack here that carries a `world.json`, +loads each one, and reports whether it is sound. + +The directory ships effectively empty (this README is the placeholder that keeps +it under version control); alternate worlds are added here as they are authored. +To serve one, point the server at it: + +```bash +UNDERSTONE_WORLD=understone/world/packs/ understone +``` + +The default Vale needs no setting at all. diff --git a/examples/door-game/understone/world/packs/cinder-wastes/events.json b/examples/door-game/understone/world/packs/cinder-wastes/events.json new file mode 100644 index 00000000..305509e0 --- /dev/null +++ b/examples/door-game/understone/world/packs/cinder-wastes/events.json @@ -0,0 +1,102 @@ +{ + "events": [ + { + "kind": "fight", + "weight": 82, + "text": "Something molten skitters out of the ash." + }, + { + "kind": "gold", + "weight": 8, + "text": "a slag-fused coin-purse cooling in the cinders", + "min": 4, + "max": 12 + }, + { + "kind": "gold", + "weight": 7, + "text": "a scatter of coins dropped by some scorched prospector", + "min": 2, + "max": 9 + }, + { + "kind": "gold", + "weight": 2, + "text": "a smelt-cache prised from beneath a toppled basalt pillar", + "min": 40, + "max": 80 + }, + { + "kind": "heal", + "weight": 5, + "text": "a cool fumarole venting clean steam", + "min": 5, + "max": 12 + }, + { + "kind": "heal", + "weight": 5, + "text": "a seep of quench-water trapped in black glass", + "min": 4, + "max": 10 + }, + { + "kind": "heal", + "weight": 5, + "text": "a shaded hollow where the ashfall cannot reach", + "min": 6, + "max": 14 + }, + { + "kind": "trap", + "weight": 5, + "text": "a thin crust gives way over a pocket of embers", + "min": 3, + "max": 9 + }, + { + "kind": "trap", + "weight": 5, + "text": "a vent of scalding gas hisses up around your boots", + "min": 2, + "max": 8 + }, + { + "kind": "trap", + "weight": 5, + "text": "a sinkhole of loose cinder swallows you to the knee", + "min": 4, + "max": 11 + }, + { + "kind": "lore", + "weight": 3, + "text": "a scorched waystone, its rune worn to a coiled, serpentine shape." + }, + { + "kind": "lore", + "weight": 3, + "text": "the ground shudders with a low note from the caldera, like something vast turning in its sleep." + }, + { + "kind": "lore", + "weight": 4, + "text": "a soot-drawn sketch pinned to a spire: a stair of glowing rock descending into a great open maw." + }, + { + "kind": "lore", + "weight": 3, + "text": "a ring of fused glass where the ash runs molten, the air above it shimmering." + }, + { + "kind": "lore", + "weight": 3, + "text": "a prospector's cairn marking the caldera road, three blackened skulls set facing the deep as warning." + }, + { + "kind": "lore", + "weight": 4, + "text": "ash-singers swear the slag rivers have no source, and that on still nights they breathe." + } + ] +} diff --git a/examples/door-game/understone/world/packs/cinder-wastes/items.json b/examples/door-game/understone/world/packs/cinder-wastes/items.json new file mode 100644 index 00000000..4f8978ad --- /dev/null +++ b/examples/door-game/understone/world/packs/cinder-wastes/items.json @@ -0,0 +1,79 @@ +[ + { + "id": "charred_shiv", + "name": "Charred Shiv", + "slot": "weapon", + "atk": 2, + "price": 0 + }, + { + "id": "obsidian_knife", + "name": "Obsidian Knife", + "slot": "weapon", + "atk": 5, + "price": 40 + }, + { + "id": "basalt_cleaver", + "name": "Basalt Cleaver", + "slot": "weapon", + "atk": 7, + "price": 80 + }, + { + "id": "molten_maul", + "name": "Molten Maul", + "slot": "weapon", + "atk": 9, + "price": 120 + }, + { + "id": "scorched_rags", + "name": "Scorched Rags", + "slot": "armor", + "def": 1, + "price": 0 + }, + { + "id": "ashplate_vest", + "name": "Ashplate Vest", + "slot": "armor", + "def": 2, + "price": 25 + }, + { + "id": "slaghide_armor", + "name": "Slaghide Armor", + "slot": "armor", + "def": 3, + "price": 50 + }, + { + "id": "obsidian_carapace", + "name": "Obsidian Carapace", + "slot": "armor", + "def": 6, + "price": 140 + }, + { + "id": "ember_tonic", + "name": "Ember Tonic", + "slot": "consumable", + "heal": 15, + "price": 12 + }, + { + "id": "cooling_draught", + "name": "Cooling Draught", + "slot": "consumable", + "heal": 40, + "price": 35 + }, + { + "id": "quenchwater_flask", + "name": "Quenchwater Flask", + "slot": "consumable", + "heal": 70, + "price": 60 + } +] diff --git a/examples/door-game/understone/world/packs/cinder-wastes/locations.json b/examples/door-game/understone/world/packs/cinder-wastes/locations.json new file mode 100644 index 00000000..5f341ccc --- /dev/null +++ b/examples/door-game/understone/world/packs/cinder-wastes/locations.json @@ -0,0 +1,52 @@ +{ + "inn": { + "kind": "inn", + "name": "The Forge-Rest", + "glyph": "⌂", + "color": "town", + "actions": ["rest", "gamble", "leave"], + "flavor": [ + "Heat-bricked walls hold back the ashfall outside.", + "The hearthwright stokes a banked forge and nods you toward a cot.", + "A night beside the coals restores you fully.", + "In the corner, a cup of knucklebones rattles for a wager." + ] + }, + "shop": { + "kind": "shop", + "name": "The Slag Market", + "glyph": "$", + "color": "town", + "actions": ["buy", "sell", "forge", "leave"], + "flavor": [ + "Stalls of cooled obsidian and scavenged plate crowd the stone.", + "Buying a weapon or armour straps it on at once.", + "Tonics go into your satchel, to quaff when the heat turns dire.", + "The smelter roars: bring slag-coin to temper your edge or your guard.", + "Spent gear sells back for half its price." + ] + }, + "healer": { + "kind": "healer", + "name": "The Ember Shrine", + "glyph": "✚", + "color": "town", + "actions": ["heal", "leave"], + "flavor": [ + "A still blue pilot-flame burns at the heart of the shrine.", + "The cinder-tender seals your burns for coin, per point of vigour." + ] + }, + "dungeon": { + "kind": "dungeon", + "name": "The Caldera Mouth", + "glyph": "∩", + "color": "dungeon", + "actions": ["descend", "challenge", "leave"], + "flavor": [ + "A throat of glowing rock drops away into furnace-dark.", + "To descend is to face a golem, then something far hotter.", + "Deeper still, the ash-singers warn, the Magma Wyrm coils and burns." + ] + } +} diff --git a/examples/door-game/understone/world/packs/cinder-wastes/monsters.json b/examples/door-game/understone/world/packs/cinder-wastes/monsters.json new file mode 100644 index 00000000..62318f8b --- /dev/null +++ b/examples/door-game/understone/world/packs/cinder-wastes/monsters.json @@ -0,0 +1,125 @@ +[ + { + "tier": 1, + "name": "Cinder Mite", + "hp": 6, + "atk": 3, + "def": 0, + "xp": 8, + "gold": 3 + }, + { + "tier": 1, + "name": "Ash Crawler", + "hp": 7, + "atk": 5, + "def": 0, + "xp": 9, + "gold": 2 + }, + { + "tier": 2, + "name": "Ember Imp", + "hp": 12, + "atk": 5, + "def": 1, + "xp": 18, + "gold": 7 + }, + { + "tier": 2, + "name": "Slag Scuttler", + "hp": 14, + "atk": 6, + "def": 1, + "xp": 20, + "gold": 9 + }, + { + "tier": 2, + "name": "the Gilded Salamander", + "hp": 16, + "atk": 6, + "def": 2, + "xp": 40, + "gold": 60, + "weight": 1, + "rare": true + }, + { + "tier": 3, + "name": "Magma Hound", + "hp": 20, + "atk": 8, + "def": 2, + "xp": 35, + "gold": 14 + }, + { + "tier": 3, + "name": "Obsidian Lurker", + "hp": 22, + "atk": 9, + "def": 2, + "xp": 38, + "gold": 16 + }, + { + "tier": 3, + "name": "the Cinder Revenant", + "hp": 30, + "atk": 11, + "def": 4, + "xp": 80, + "gold": 110, + "weight": 1, + "rare": true + }, + { + "tier": 4, + "name": "Basalt Golem", + "hp": 38, + "atk": 12, + "def": 4, + "xp": 70, + "gold": 30 + }, + { + "tier": 4, + "name": "Ashen Wraith", + "hp": 35, + "atk": 13, + "def": 4, + "xp": 65, + "gold": 28 + }, + { + "tier": 5, + "name": "Slag Drake", + "hp": 60, + "atk": 18, + "def": 6, + "xp": 140, + "gold": 60 + }, + { + "tier": 5, + "name": "Caldera Reaver", + "hp": 55, + "atk": 17, + "def": 6, + "xp": 130, + "gold": 55 + }, + { + "tier": 6, + "name": "the Magma Wyrm", + "hp": 120, + "atk": 24, + "def": 8, + "xp": 400, + "gold": 250, + "boss": true, + "id": "magma_wyrm" + } +] diff --git a/examples/door-game/understone/world/packs/cinder-wastes/terrain.json b/examples/door-game/understone/world/packs/cinder-wastes/terrain.json new file mode 100644 index 00000000..805d1f3c --- /dev/null +++ b/examples/door-game/understone/world/packs/cinder-wastes/terrain.json @@ -0,0 +1,44 @@ +{ + ".": { + "key": "ash", + "glyph": "░", + "walkable": true, + "encounter_rate": 0.1, + "color": "floor" + }, + "A": { + "key": "spire", + "glyph": "▲", + "walkable": false, + "encounter_rate": 0.0, + "color": "tree" + }, + "~": { + "key": "slag", + "glyph": "≈", + "walkable": false, + "encounter_rate": 0.0, + "color": "water" + }, + "=": { + "key": "basalt", + "glyph": "=", + "walkable": true, + "encounter_rate": 0.02, + "color": "floor" + }, + "c": { + "key": "cinder", + "glyph": "▒", + "walkable": true, + "encounter_rate": 0.25, + "color": "tree" + }, + "#": { + "key": "caldera", + "glyph": "▓", + "walkable": false, + "encounter_rate": 0.0, + "color": "wall" + } +} diff --git a/examples/door-game/understone/world/packs/cinder-wastes/world.json b/examples/door-game/understone/world/packs/cinder-wastes/world.json new file mode 100644 index 00000000..15984a60 --- /dev/null +++ b/examples/door-game/understone/world/packs/cinder-wastes/world.json @@ -0,0 +1,132 @@ +{ + "name": "The Cinder Wastes", + "width": 96, + "height": 48, + "spawn": [20, 24], + "legend": { + ".": "ash", + "A": "spire", + "~": "slag", + "=": "basalt", + "c": "cinder", + "#": "caldera" + }, + "terrain_rows": [ + "################################################################################################", + "#.........................................................ccccccccccccccccccccccccccccccccccccc#", + "#............A......................A.....................cAccccccccccccccccccccccAcccccccccccc#", + "#.......A......................A......................A...cccccccccccccccccccAccccccccccccccccc#", + "#..A..~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~cccccccc#", + "#.....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~cccAcccc#", + "#.....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~cccccccc#", + "#.....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~cccccccc#", + "#.....A......................A......................A.....cccccccccccccccccAccccccccccccccccccc#", + "#A......................A......................A..........ccccccccccccAccccccccccccccccccccccAc#", + "#..................A......................A...............cccccccAcc#####cccccccccccccccAcccccc#", + "#.............A......................A....................ccAccccccc#ccc#ccccccccccAccccccccccc#", + "#........A......................A......................A..cccccccc====cc#cccccAcccccccccccccccc#", + "#...A......................A......................A.......cccccccc=c#####Accccccccccccccccccccc#", + "#.....................A......................A............cccccccc=cAccccccccccccccccccccccAccc#", + "#................A......................A.................cccccAcc=cccccccccccccccccccAcccccccc#", + "#...........A......................A......................Accccccc=ccccccccccccccAccccccccccccc#", + "#......A......................A......................A....cccccccc=cccccccccAcccccccccccccccccc#", + "#.A......................A......................A.........cccccccc=ccccAccccccccccccccccccccccA#", + "#...................A......................A..............cccccccc=ccccccccccccccccccccccAccccc#", + "#.....................................A...................cccAcccc=cccccccccccccccccAcccccccccc#", + "#.........A......................A......................A.cccccccc=ccccccccccccAccccccccccccccc#", + "#....A.............................................A......cccccccc=cccccccAcccccccccccccccccccc#", + "#.............................................A...........cccccccc=ccAccccccccccccccccccccccAcc#", + "#.=================================================================ccccccccccccccccccccAccccccc#", + "#............A......................A.....................cAccccccccccccccccccccccAcccccccccccc#", + "#.......A......................A......................A...cccccccccccccccccccAccccccccccccccccc#", + "#..A.............................................A........ccccccccccccccAcccccccccccccccccccccc#", + "#...........................................A.............cccccccccAccccccccccccccccccccccAcccc#", + "#...............A......................A..................ccccAccccccccccccccccccccccAccccccccc#", + "#..........A......................A......................AccccccccccccccccccccccAcccccccccccccc#", + "#.....A......................A......................A.....cccccccccccccccccAccccccccccccccccccc#", + "#A......................A......................A..........ccccccccccccAccccccccccccccccccccccAc#", + "#..................A......................A...............cccccccAccccccccccccccccccccccAcccccc#", + "#.............A......................A....................ccAccccccccccccccccccccccAccccccccccc#", + "#........A......................A......................A..ccccccccccccccccccccAcccccccccccccccc#", + "#...A......................A......................A.......cccccccccccccccAccccccccccccccccccccc#", + "#.....................A......................A............ccccccccccAccccccccccccccccccccccAccc#", + "#................A......................A.................cccccAccccccccccccccccccccccAcccccccc#", + "#...........A......................A......................AccccccccccccccccccccccAccccccccccccc#", + "#......A......................A......................A....ccccccccccccccccccAcccccccccccccccccc#", + "#.A......................A......................A.........cccccccccccccAccccccccccccccccccccccA#", + "#...................A......................A..............ccccccccAccccccccccccccccccccccAccccc#", + "#..............A......................A...................cccAccccccccccccccccccccccAcccccccccc#", + "#.........A......................A......................A.cccccccccccccccccccccAccccccccccccccc#", + "#....A......................A......................A......ccccccccccccccccAcccccccccccccccccccc#", + "#.........................................................ccccccccccccccccccccccccccccccccccccc#", + "################################################################################################" + ], + "locations": [ + { + "key": "inn", + "x": 18, + "y": 24 + }, + { + "key": "shop", + "x": 22, + "y": 24 + }, + { + "key": "healer", + "x": 20, + "y": 22 + }, + { + "key": "dungeon", + "x": 70, + "y": 12 + } + ], + "zones": [ + { + "key": "ash_flats", + "rect": [30, 18, 60, 36], + "tier_lo": 1, + "tier_hi": 2 + }, + { + "key": "caldera_deep", + "rect": [60, 8, 82, 22], + "tier_lo": 3, + "tier_hi": 5 + } + ], + "settings": { + "daily_turns": 10, + "rest_cost": 15, + "heal_cost_per_hp": 2, + "starting_gold": 20, + "starting_weapon": "charred_shiv", + "starting_armor": "scorched_rags", + "start_hp": 20, + "start_atk": 3, + "start_def": 0, + "xp_base": 100, + "growth": { + "max_hp": 6, + "atk": 2, + "def": 1 + }, + "bestow_daily_budget": 25, + "dungeon_tiers": [3, 4, 5], + "boss_monster": "magma_wyrm", + "wyrm_min_level": 6, + "ambush_min_level": 3, + "ambush_level_band": 2, + "ambush_gold_pct": 25, + "post_daily_cap": 5, + "gamble_max_bet": 50, + "gamble_daily_cap": 5, + "satchel_max": 3, + "forge_base_cost": 60, + "forge_max_plus": 3, + "rare_drop_item": "cooling_draught", + "watch_theme": "ember" + } +}