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

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

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

739 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The pack-authoring command surface — validate a pack and scaffold a new one.
This module is deliberately pure: it imports only the loader and the standard
library, takes no part in argument parsing (``server.main`` owns the argparse
front end), and writes to the streams it is handed. That keeps the authoring
loop — ``newpack`` then ``validate`` — testable as plain function calls.
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
a non-zero code. This is the feedback half of the loop.
* :func:`cli_newpack` scaffolds a new pack: it copies the bundled world as a
starting template and writes an ``AUTHORING.md`` manual whose bands table is
generated from the loader's own band data, so the documented limits can
never drift from the enforced ones.
* :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
import shutil
import sys
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, bundled_world_dirs, loader
if TYPE_CHECKING:
from pathlib import Path
from understone.engine.world import World
# The six packaged content files copied verbatim as a new pack's template.
_PACK_FILES = (
"terrain.json",
"monsters.json",
"items.json",
"locations.json",
"events.json",
"world.json",
)
def cli_validate(pack_dir: Path, out: TextIO | None = None, err: TextIO | None = None) -> int:
"""Load *pack_dir* and report; return 0 if sound, 2 if it fails to load.
On success a pack report is written to *out* and the function returns 0.
On any :class:`WorldLoadError` the loader's message — which names the
file, index, and field at fault — is written to *err* and the function
returns 2. The author iterates against that message until the pack loads.
*out*/*err* default to the live ``sys.stdout``/``sys.stderr`` resolved at
call time, so a caller (or pytest's capture) may redirect them.
"""
out = out if out is not None else sys.stdout
err = err if err is not None else sys.stderr
try:
world = loader.load_world(pack_dir)
except WorldLoadError as exc:
print(f"The pack is flawed: {exc}", file=err)
return 2
print(_pack_report(world), file=out)
return 0
def cli_newpack(dest: Path, out: TextIO | None = None, err: TextIO | None = None) -> int:
"""Scaffold a new content pack at *dest*; return 0, or 2 if *dest* is taken.
Refuses to write into an existing non-empty directory (so an author never
clobbers work in progress). Otherwise it creates *dest*, copies the six
packaged content files as a starting template, and writes an
``AUTHORING.md`` manual generated from the live loader bands. The author
then edits or regenerates the JSON and runs ``validate``.
*out*/*err* default to the live ``sys.stdout``/``sys.stderr`` resolved at
call time, so a caller (or pytest's capture) may redirect them.
"""
out = out if out is not None else sys.stdout
err = err if err is not None else sys.stderr
if dest.exists() and dest.is_dir() and any(dest.iterdir()):
print(f"refusing to scaffold into non-empty directory: {dest}", file=err)
return 2
if dest.exists() and not dest.is_dir():
print(f"refusing to scaffold over a file: {dest}", file=err)
return 2
dest.mkdir(parents=True, exist_ok=True)
for name in _PACK_FILES:
shutil.copyfile(PACKAGED_WORLD_DIR / name, dest / name)
(dest / "AUTHORING.md").write_text(build_authoring_md(), encoding="utf-8")
print(f"Scaffolded a new pack at {dest}.", file=out)
print("Six content files plus AUTHORING.md are in place; the template is the", file=out)
print("shipped Vale of Understone, ready to edit or regenerate.", file=out)
print(f"Next: edit or regenerate the JSON, then: understone validate {dest}", file=out)
return 0
def 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*.
Counts and shares are computed from the runtime world so the figures match
what the engine will actually run, not what the JSON nominally declares.
"""
settings = world.settings
boss_count = sum(1 for m in world.monsters if m.boss)
fight_share = _fight_share_pct(world)
lines = [
f"{world.name}{world.width}x{world.height}",
f" monsters : {len(world.monsters)} ({boss_count} boss)",
f" items : {len(world.items)}",
f" zones : {len(world.zones)}",
f" events : {len(world.events)} ({fight_share}% fight by weight)",
(
" settings : "
f"{settings.daily_turns} turns/day, "
f"bestow budget {settings.bestow_daily_budget}, "
f"Wyrm gate level {settings.wyrm_min_level}"
),
"",
"This pack is sound. The door stands open.",
]
return "\n".join(lines)
def _fight_share_pct(world: World) -> int:
"""Return the share of overworld encounter weight that is a ``fight``.
Reported by weight, not row count, because weight is the draw probability
the engine actually rolls against — it is the number an author tunes to hit
the ~55% fight feel.
"""
total = sum(e.weight for e in world.events)
if total == 0:
return 0
fight = sum(e.weight for e in world.events if e.kind == "fight")
return round(100 * fight / total)
def build_authoring_md() -> str:
"""Build the AUTHORING.md manual, bands table and glyph palette included.
Both the bands section and the safe-glyph palette are generated from live
source — the loader's own band tables and ``textwidth.SAFE_PALETTE`` — so
the documented limits and the suggested glyphs are exactly what the loader
enforces and admits, and cannot silently drift from it.
"""
md = _AUTHORING_TEMPLATE.replace("{{BANDS}}", _render_bands())
md = md.replace("{{PALETTE}}", _render_palette())
md = md.replace("{{COLOR_ROLES}}", _render_color_roles())
return md.replace("{{VALIDATE_COVERAGE}}", _render_validate_coverage())
def _render_bands() -> str:
"""Render the bands reference straight from the loader's band data."""
parts: list[str] = []
parts.append("### Map and counts\n")
parts.append(
f"* Map width and height: each `{loader.MAP_DIM_MIN}`..`{loader.MAP_DIM_MAX}` cells."
)
# monsters/items/events are their own files; locations and zones are lists
# inside world.json, so name each cap's real source.
count_source = {
"monsters": "`monsters.json`",
"items": "`items.json`",
"events": "`events.json`",
"locations": "`world.json` → `locations`",
"zones": "`world.json` → `zones`",
}
for name, cap in loader.MAX_COUNTS.items():
parts.append(f"* {count_source[name]}: at most `{cap}` entries.")
parts.append(
f"* Display names (monster, item, location): at most "
f"`{loader.MAX_NAME_LEN}` printable characters."
)
parts.append(
"* Map glyphs (terrain, location, legend keys): exactly one terminal "
"column (one printable code point, no fullwidth runes, no combining "
"marks — see the width rule above), and never one of "
+ ", ".join(f"`{g}`" for g in _reserved_glyph_list())
+ " (the frame box-drawing lines and the `@`/`☻` player markers)."
)
parts.append("")
parts.append("### Economy and progression settings (`world.json` → `settings`)\n")
parts.append("| field | allowed range |")
parts.append("| --- | --- |")
for field_name, (lo, hi) in loader.SETTINGS_BANDS.items():
rng = f"{lo}..{hi}" if hi is not None else f"{lo} or more"
parts.append(f"| `{field_name}` | `{rng}` |")
parts.append("")
parts.append("### Overworld event amounts (`events.json`, per kind)\n")
parts.append("| kind | min..max amount |")
parts.append("| --- | --- |")
for kind, (lo, hi) in loader.EVENT_AMOUNT_BANDS.items():
parts.append(f"| `{kind}` | `{lo}..{hi}` |")
parts.append(
"\n(`fight` and `lore` carry no amount; `fight` draws its foe from the "
"zone tier band, `lore` is pure flavour text.)\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."
)
parts.append("\n### The ore-gated forge (`world.json` → `settings`)\n")
ore_per = loader.SETTINGS_BANDS["forge_ore_per_plus"]
dungeon = loader.SETTINGS_BANDS["ore_dungeon_drop"]
parts.append(
"Forging a +1 edge now costs both GOLD and ORE — a `material` item the "
"hero earns in combat, never buys. Four settings bind it:"
)
parts.append(
"* `forge_ore_item` — REQUIRED. The item id of your world's forge ore; "
"it must name an `items.json` entry whose `slot` is `material` (an "
"unknown id or a non-material slot is a load error). The Vale uses "
"`iron_ore`."
)
parts.append(
f"* `forge_ore_per_plus` — band `{ore_per[0]}..{ore_per[1]}`. Ore per +1 "
f"step: a +N forge costs `(current_plus + 1) * forge_ore_per_plus` ore. "
f"{_forge_ore_worked_example()}"
)
parts.append(
f"* `ore_dungeon_drop` — band `{dungeon[0]}..{dungeon[1]}`. Ore granted "
f"on every WON dungeon rung — the reliable source. The Vale drops 2."
)
parts.append(
"* `ore_forest_chance` — a `0.0`..`1.0` probability (a float, validated "
"outside the integer band table). The chance a WON forest fight yields "
"one ore — the occasional bonus source. The Vale uses `0.2`."
)
parts.append(
"\nOre rides the satchel as a stack, so it shares the `satchel_max` "
"DISTINCT-stack budget with potions (per-stack quantity is unbounded). "
"Tune the two sources so a hero who descends steadily earns enough ore "
"to forge without grinding — the `simulate` bot will tell you if the "
"gate stalls a winnable run."
)
return "\n".join(parts)
def _forge_ore_worked_example() -> str:
"""Render the per-step ore costs from the bundled Vale's live forge settings.
The starter template :func:`cli_newpack` copies IS the bundled Vale, so the
worked figures are computed from its actual ``forge_ore_per_plus`` and
``forge_max_plus`` rather than hardcoded — a retune of the template moves
the manual with it. The steps are ``per_plus * (i + 1)`` for each ``i`` in
``range(forge_max_plus)``; the total is what it costs to max one slot.
"""
settings = loader.load_world(PACKAGED_WORLD_DIR).settings
per_plus = settings.forge_ore_per_plus
max_plus = settings.forge_max_plus
steps = [per_plus * (i + 1) for i in range(max_plus)]
if not steps:
return (
f"At the template's value of {per_plus}, slots cannot be forged (`forge_max_plus` 0)."
)
ladder = ", ".join(str(cost) for cost in steps)
total = sum(steps)
return (
f"At the template's value of {per_plus}, the steps cost {ladder} ore "
f"({total} ore to max a slot at `forge_max_plus` {max_plus})."
)
def _reserved_glyph_list() -> list[str]:
"""Return the reserved glyphs in a stable, readable order for the manual."""
box = [g for g in "┌┐└┘─│═" if g in loader.RESERVED_GLYPHS]
actors = [g for g in "@☻" if g in loader.RESERVED_GLYPHS]
return box + actors
def _render_palette() -> str:
"""Render the safe-glyph appendix straight from ``textwidth.SAFE_PALETTE``.
The glyphs are emitted in their declared order, wrapped in backticks so the
monospace renders them as discrete cells. Generated from the live constant,
so the suggested palette is exactly the set the loader's width gate admits.
"""
glyphs = " ".join(f"`{g}`" for g in SAFE_PALETTE)
return (
"Any single-column glyph the loader accepts is fair game, but these "
"carry the period BBS / CP437 flavour and are all guaranteed safe:\n\n"
f"{glyphs}"
)
def _render_color_roles() -> str:
"""Render the author-assignable colour roles, generated from the Color enum.
The Watch knows how to paint exactly the roles in ``screen.palette.Color``;
``Color.assignable()`` is the single source for which of those an author may
put on terrain or a location (the runtime overlay roles an actor/item wears,
and the DEFAULT fallback, are filtered out there). Generated from the enum,
so the documented vocabulary can never drift from what the Watch can
actually colour — the same can't-drift discipline as the bands and the
safe-glyph palette. ``color`` itself stays advisory: the loader does not
validate it, so a typo is harmless and an unknown role just paints as the
default; these are simply the roles the Watch recognises.
"""
from understone.screen.palette import Color
return ", ".join(f"`{role.value}`" for role in Color.assignable())
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`, '
"`rare_drop_item` → a consumable item id, and `forge_ore_item` → a "
"`material` 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
A *world pack* is a directory of six JSON files that the server loads at start
to become the entire game world — its map, its monsters, its economy, its
endgame. There is no code to write: you describe a world as data, the loader
validates it hard, and the server runs it. This file is the manual; you can
follow it cold, by hand or with an LLM.
The loop is short:
1. `understone newpack mypack` — scaffold this template (you are reading the
copy it wrote into `mypack/AUTHORING.md`).
2. Edit or regenerate the JSON files to describe your world.
3. `understone validate mypack` — the loader checks the pack and either prints
a report ending **"This pack is sound. The door stands open."** or tells you
exactly which file, row, and field is wrong.
4. Repeat step 2 until it is sound, then serve it:
`UNDERSTONE_WORLD=mypack understone`.
The loader's error messages are written FOR you: every failure names the file,
the index, and the field, and says what was expected. Treat them as the
feedback loop — iterate until the report says the door stands open.
---
## The six files and how they fit together
| file | shape | holds |
| --- | --- | --- |
| `terrain.json` | object keyed by legend char | terrain kinds: glyph, walkability, encounter rate |
| `monsters.json` | list | monster stat blocks, tiered; one flagged the boss |
| `items.json` | list | weapons, armour, consumables for the shop |
| `locations.json` | object keyed by location key | building kinds: name, glyph, menu actions, flavour |
| `events.json` | object with an `events` list | the weighted overworld encounter table |
| `world.json` | object | the map, placements, zones, and `settings` that bind it all |
The cross-references the loader enforces:
* every character in `world.json` → `legend` must name a terrain `key` from
`terrain.json`; every character in `terrain_rows` must be in that legend;
* every placement in `world.json` → `locations` must name a key defined in
`locations.json`, and must sit on walkable terrain;
* `settings.starting_weapon` / `starting_armor` must be ids from
`items.json`; `settings.boss_monster` must be an id from `monsters.json`
that is flagged `"boss": true`; `settings.rare_drop_item` must be an id from
`items.json` whose `slot` is `consumable`; `settings.forge_ore_item` must be
an id from `items.json` whose `slot` is `material`;
* every tier in `settings.dungeon_tiers` must be backed by a NON-boss monster;
* every zone's tier band must overlap at least one monster tier.
---
## File-by-file schema
### `terrain.json`
An object whose keys are the single-character legend symbols used in the map.
```json
{
".": {"key": "grass", "glyph": ".", "walkable": true, "encounter_rate": 0.1, "color": "floor"}
}
```
* `key` — internal name the map legend resolves to.
* `glyph` — the single character drawn on the map (see glyph rules below).
* `walkable` — may a player stand here.
* `encounter_rate` — `0.0`..`1.0`, the per-step chance a walk rolls the event
table on this terrain.
* `color` — a palette role string. It is **advisory and not validated**: the
loader stores it but the text frame draws glyphs only (it is monochrome), so
any string loads and an unrecognised role simply maps to the default at render
time. Where colour DOES show is the live Watch page, which paints each role a
distinct hue. The roles the Watch knows how to paint — pick the closest fit —
are: {{COLOR_ROLES}}. A typo here is harmless, not a load error; it just
paints as the default. The four runtime overlay colours (the hero, rival
players, monsters, dropped items) are set by the engine, not assignable here.
### `monsters.json`
A list of stat blocks. `tier` groups foes by difficulty; zones and the dungeon
gauntlet draw from tiers. Exactly one monster should be the boss.
```json
{"tier": 2, "name": "Goblin", "hp": 12, "atk": 5, "def": 1, "xp": 18, "gold": 7}
```
The boss adds an `id` and `"boss": true`, and is referenced by
`settings.boss_monster`:
```json
{"tier": 6, "name": "the Wyrm Below", "hp": 120, "atk": 24, "def": 8,
"xp": 400, "gold": 250, "boss": true, "id": "wyrm_below"}
```
Two optional fields tune random forest encounters. `weight` (default `10`,
must be `> 0`) biases the weighted draw within a zone band — a low weight
surfaces seldom — and `rare` (default `false`) marks a named beast that, on
its kill, fires a public Herald flash and drops the pack's `rare_drop_item`
into the slayer's satchel. Rung guardians ignore both (a rung always takes the
FIRST monster of its tier, never a weighted roll), so a rare should not be the
first entry of a tier that backs a `dungeon_tiers` rung.
```json
{"tier": 2, "name": "the Gilded Stag", "hp": 16, "atk": 6, "def": 2,
"xp": 40, "gold": 60, "weight": 1, "rare": true}
```
### `items.json`
A list of equipment, consumables, and crafting materials. `slot` is `weapon`,
`armor`, `consumable`, or `material`. Weapons add `atk`, armour adds `def`,
consumables `heal`; a `material` carries none of these — it is the forge ORE,
carried in the satchel and spent at the forge.
```json
{"id": "short_sword", "name": "Short Sword", "slot": "weapon", "atk": 5, "price": 40}
```
The forge ore is a `material` item the player EARNS in combat (not the shop):
price it `0` — ore is never bought or sold — and point `settings.forge_ore_item`
at its id. A won dungeon rung always drops `settings.ore_dungeon_drop` of it, and
a won forest fight has a `settings.ore_forest_chance` chance of one.
```json
{"id": "iron_ore", "name": "Iron Ore", "slot": "material", "price": 0}
```
### `locations.json`
An object keyed by location key. Each entry is a building kind with a menu of
`actions` the player may take inside it.
```json
{
"inn": {"kind": "inn", "name": "The Sleeping Drake", "glyph": "I",
"color": "town", "actions": ["rest", "gamble", "leave"],
"flavor": ["Lamplight pools on worn oak tables."]}
}
```
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`, `deposit`, `withdraw`, `gamble`, `leave` |
| `shop` | `buy`, `sell`, `forge`, `leave` |
| `healer` | `heal`, `leave` |
| `dungeon` | `descend`, `challenge`, `leave` |
The inn's `deposit`/`withdraw` are the VAULT: a player banks gold into the inn
strongbox (`deposit amount=<gold>`) and draws it back (`withdraw amount=<gold>`).
Banked gold is SAFE from ambush — a sleeping-robber only ever lifts gold in hand
— and it SURVIVES the Wyrm-win legacy reset, so it is the one store of wealth
that carries across runs. Both cost no turn.
`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`
An object with an `events` list — the weighted overworld encounter table the
server rolls as a player walks.
```json
{"events": [
{"kind": "fight", "weight": 82, "text": "Something snarls out of the brush."},
{"kind": "gold", "weight": 8, "text": "a rotted coin-purse", "min": 4, "max": 12}
]}
```
* `kind` — `fight`, `gold`, `heal`, `trap`, or `lore`.
* `weight` — relative draw weight (`> 0`).
* `text` — required (non-empty) for every kind except `fight`.
* `min`/`max` — required for the value-bearing kinds (`gold`, `heal`, `trap`).
There MUST be at least one `fight` row, or a walk could never find a monster.
### `world.json`
The binding file: `name`, `width`, `height`, `spawn` `[x, y]` (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}
```
---
## The bands — the limits the loader enforces
These are generated from the loader's own tables, so they are exactly what
`validate` checks. A value outside its band is a load error.
{{BANDS}}
---
## Glyph width — the one-column rule
Every glyph drawn on the map must occupy **exactly one terminal column**. The
frames are box-drawing rectangles; a glyph that renders two columns (a CJK
ideograph like `龍`, an emoji like `🌲`, a fullwidth ``) shoves its row right
and tears the border, and a combining mark (a decomposed `é`, a lone accent)
stacks onto its neighbour and breaks the count the other way. The loader
rejects all of these at load.
What is admitted is judged for the **Western monospace** metrics every
Understone surface actually uses (the Watch's pinned font stack, a chat
client's code block): under those metrics the East-Asian "Ambiguous" width
class renders single-column, and that class is the CP437 heartland — `█`, `♣`,
`↑`, `∩`, `≈`, `★` all live there — so the rule admits it and bars only the
genuinely double-width Wide and Fullwidth classes.
### Safe glyph palette
{{PALETTE}}
---
## Design guidance
**Turn economy.** `daily_turns` is the whole pacing lever: only fighting,
descending, and challenging the Wyrm spend a turn (moving, resting, shopping
are free). A small budget (the Vale uses 10) makes this a correspondence game
played a little each day. Set `rest_cost`, `heal_cost_per_hp`, and shop prices
so a day's gold roughly covers a day's recovery — too cheap and there is no
tension, too dear and a hero stalls.
**Tier curve.** Lay monster tiers as a rising staircase: each tier should be a
real step up in `hp`/`atk` and a real step up in `xp`/`gold`, so the reward of
pushing into a harder zone pays for the risk. Keep two or three foes per tier
for variety. The boss should tower over the top random tier — it is the climax.
**Encounter feel.** Aim for roughly 55% of overworld encounter WEIGHT on
`fight` rows; the rest is the texture of travel — small gold finds, healing
springs, harmless traps, and lore that hints at the endgame. (The validate
report prints your actual fight share so you can tune it.)
**Glyphs.** Map glyphs must render as exactly one terminal column (see the
one-column rule above) and must never collide with the frame's box-drawing
lines or the `@`/`☻` player markers. Pick glyphs that read at a glance — the
bundled Vale uses `.` open ground, `≋` water, `♣` tree, `⌂` inn, `$` shop, `✚`
healer, `∩` dungeon — and lean on the safe palette for period flavour.
**Boss rules.** Exactly one monster carries `"boss": true` and an `id`, and
`settings.boss_monster` points at it. The boss is the only win condition and is
faced only through the `challenge` verb, gated by `settings.wyrm_min_level`. A
boss tier must NOT appear in `settings.dungeon_tiers`: the gauntlet excludes
boss monsters, so a boss-only rung would be unfillable — back every dungeon
tier with at least one ordinary monster.
**The deep, the satchel, and the forge.** `dungeon_tiers` is now a RUNG LADDER
fought one rung per `descend` — list the tiers shallow-to-deep, and make it long
enough to feel like a journey (the Vale uses three). The Wyrm gates on reaching
the floor as well as on level. Size the satchel with `satchel_max` — it caps the
DISTINCT stacks the bag holds (potions and ore each take a slot; per-stack
quantity is unbounded), and it is the death-save reserve, so keep it small (the
Vale carries 3). The forge is the late-game GOLD-AND-ORE sink: `forge_base_cost`
is the gold price of a +1 edge and scales up each tier (`base * (current_plus +
1)`), capped at `forge_max_plus`, and each step ALSO costs ore (see the ore-gated
forge above). Ore is won in the deep (and seldom in the forest), so the forge is
fed by descending — price the gold so a fully-forged piece is a multi-day saving,
and set the ore sources so a steady delver can afford it without a grind.
**Rare beasts.** A rare monster is a small legend: give it a low `weight` so it
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 — `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, 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.
---
## The validate loop
Run `understone validate mypack` after every change. On success you get a
report — name, size, monster/item/zone/event counts, fight share, and the key
settings — ending in **"This pack is sound. The door stands open."** On
failure you get one precise line naming the file, the row, and the field.
The error messages are deliberately instructive: they are the authoring API.
Keep editing and re-validating until the door stands open, then point the
server at your pack with `UNDERSTONE_WORLD=mypack`.
### 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.
"""