feat(examples): Understone v0.8 — worlds without authors

The slice that proves the pipeline: a second world authored entirely by an
LLM from AUTHORING.md and the validator alone, plus the tooling to discover,
theme, and balance-test any world.

- The dogfood: "The Cinder Wastes" — an ashen volcanic underworld (slag
  rivers, a caldera mouth, a Magma Wyrm) — was written cold by an agent
  given only the generated authoring manual and `understone validate`. It
  passed validation on the FIRST run with zero failures. Its stumble log
  found six places where the manual stated a rule the validator didn't
  enforce; those became permanent hardening (below). It ships in
  understone/world/packs/ and glows ember on the lobby TV.
- `understone worlds` lists every bundled world (the Vale + alternates)
  with its load status, via one shared discovery path.
- Per-world Watch themes: settings.watch_theme (phosphor/amber/ice/ember,
  loader-validated) repaints the spectator page; the Vale's green is
  byte-for-byte unchanged.
- The sim harness: a pure, seeded, greedy bot plays the real game façade
  over an injected day-stepping clock and emits a balance report —
  `understone simulate PATH [--days N] [--seeds K]`. It SLAYS THE WYRM on
  both worlds (Vale ~day 13, Cinder ~day 25), so the whole v0.1->v0.7 loop
  is proven winnable end-to-end by an unclever bot through the real stack.
- Loader hardening from the dogfood: a rare monster may not occupy a
  dungeon-rung guardian slot (it would silently become a fixed foe and
  leave the rare pool); exactly one monster may be the boss; and the
  boss-tier error now says "no non-boss monster," matching the manual.
  AUTHORING gained a generated "what validate checks vs. what it cannot"
  section so the rule/guidance boundary is honest.

Review hardened the bot for arbitrary authored packs (a MENU-mode fight
spin and four related robustness gaps that were latent on the shipped
worlds), and documented that final_level reads post-legacy-reset. Tests
359 -> 373; both worlds still win byte-identically after the fixes.
This commit is contained in:
Patrick Buckley
2026-06-12 22:15:05 -07:00
parent dcc0e5fb0a
commit 65e7b404bc
25 changed files with 2708 additions and 30 deletions
+32 -4
View File
@@ -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.
+1 -1
View File
@@ -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"
+2
View File
@@ -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]
+64
View File
@@ -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.
+1 -1
View File
@@ -6,4 +6,4 @@ import understone
def test_version_present() -> None:
assert understone.__version__ == "0.7.0"
assert understone.__version__ == "0.8.0"
+306
View File
@@ -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.1v0.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
@@ -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
+97 -2
View File
@@ -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"
+206
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.7.0"
__version__ = "0.8.0"
+169 -18
View File
@@ -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: <short reason>`` — 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=<path> (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: <reason>`` 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.
"""
@@ -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"
+17 -1
View File
@@ -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()
+960
View File
@@ -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
+63
View File
@@ -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 <span> 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 || [];
@@ -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 ``<slug>/`` 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
@@ -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"
}
}
+53 -1
View File
@@ -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)
@@ -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/<slug> understone
```
The default Vale needs no setting at all.
@@ -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."
}
]
}
@@ -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
}
]
@@ -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."
]
}
}
@@ -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"
}
]
@@ -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"
}
}
@@ -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"
}
}