mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ import understone
|
||||
|
||||
|
||||
def test_version_present() -> None:
|
||||
assert understone.__version__ == "0.7.0"
|
||||
assert understone.__version__ == "0.8.0"
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Tests for the balance instrument (the greedy bot simulator).
|
||||
|
||||
These run the REAL game façade end-to-end, so they double as the fiercest
|
||||
integration test in the suite: determinism (same inputs → identical report),
|
||||
that the greedy bot makes genuine progress over a Vale run, that its realized
|
||||
fight share lands in a sane band, that a multi-seed sweep aggregates and the
|
||||
report renders — and the single best end-to-end assertion, that a short seed
|
||||
sweep actually SLAYS THE WYRM, proving the whole v0.1–v0.7 loop is winnable by
|
||||
an unclever bot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from understone import sim
|
||||
from understone.engine.models import LocationDef, Mode, Zone
|
||||
from understone.engine.rng import GameRNG
|
||||
from understone.game import Game
|
||||
from understone.persistence import Store
|
||||
from understone.sim import BalanceReport, simulate
|
||||
|
||||
from .conftest import make_monster, make_world
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# determinism
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_same_inputs_give_identical_report() -> None:
|
||||
"""Same (pack, days, seed) → byte-identical BalanceReport (frozen + seeded)."""
|
||||
a = simulate(PACK, 20, 5)
|
||||
b = simulate(PACK, 20, 5)
|
||||
assert a == b
|
||||
assert isinstance(a, BalanceReport)
|
||||
|
||||
|
||||
def test_different_seeds_diverge() -> None:
|
||||
"""Different seeds produce different runs (the RNG actually threads through)."""
|
||||
a = simulate(PACK, 20, 1)
|
||||
b = simulate(PACK, 20, 2)
|
||||
# The runs are not identical (some headline measure differs).
|
||||
assert (a.fights_fought, a.total_gold_earned, a.day_of_first_wyrm_kill) != (
|
||||
b.fights_fought,
|
||||
b.total_gold_earned,
|
||||
b.day_of_first_wyrm_kill,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# progress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bot_makes_progress_over_thirty_days() -> None:
|
||||
"""A 30-day Vale run climbs past level 1 and actually fights."""
|
||||
r = simulate(PACK, 30, 1)
|
||||
assert r.final_level > 1
|
||||
assert r.fights_fought > 0
|
||||
assert r.total_gold_earned > 0
|
||||
# It also plumbs the deep — the rung ladder is reachable for a geared bot.
|
||||
assert r.rungs_cleared > 0
|
||||
|
||||
|
||||
def test_realized_fight_share_in_sane_band() -> None:
|
||||
"""The bot's fight share is a real fraction and forest-fight dominant.
|
||||
|
||||
A greedy XP grinder spends most of its turns fighting the wood (the rest are
|
||||
the handful of descents and the Wyrm bout), so the share is high — but it is
|
||||
a genuine fraction in (0, 1], never a degenerate 0 or a value out of range.
|
||||
"""
|
||||
r = simulate(PACK, 30, 3)
|
||||
assert 0.0 < r.realized_fight_share <= 1.0
|
||||
# Fights dominate the turn-spend, but descents/challenges exist too, so the
|
||||
# share is below a hard 1.0 floor only loosely — assert the sane half-band.
|
||||
assert r.realized_fight_share >= 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reporting & sweep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_report_renders_without_crashing() -> None:
|
||||
r = simulate(PACK, 15, 1)
|
||||
text = sim._render_report("The Vale of Understone", r)
|
||||
assert "greedy bot" in text
|
||||
assert "final level" in text
|
||||
assert "Wyrm slain" in text
|
||||
|
||||
|
||||
def test_cli_simulate_single_seed_renders(tmp_path: Path) -> None:
|
||||
out = StringIO()
|
||||
rc = sim.cli_simulate(PACK, 15, 1, out=out)
|
||||
assert rc == 0
|
||||
assert "The Vale of Understone" in out.getvalue()
|
||||
assert "fight share" in out.getvalue()
|
||||
|
||||
|
||||
def test_cli_simulate_sweep_aggregates() -> None:
|
||||
"""A --seeds sweep prints per-seed lines plus an aggregate with spreads."""
|
||||
out = StringIO()
|
||||
rc = sim.cli_simulate(PACK, 20, 1, out=out, seeds=3)
|
||||
assert rc == 0
|
||||
text = out.getvalue()
|
||||
assert "3 seeds" in text
|
||||
assert "aggregate" in text
|
||||
# Per-seed lines for each of the three seeds.
|
||||
for seed in (1, 2, 3):
|
||||
assert f"seed {seed:>3}" in text or f"seed {seed}" in text
|
||||
# The aggregate carries a mean [min..max] spread.
|
||||
assert "[" in text and "]" in text
|
||||
|
||||
|
||||
def test_sweep_reports_are_each_deterministic() -> None:
|
||||
"""Each seed in a sweep is independently reproducible by single simulate."""
|
||||
seed = 4
|
||||
swept = simulate(PACK, 20, seed)
|
||||
again = simulate(PACK, 20, seed)
|
||||
assert swept == again
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the load-bearing assertion: the world is winnable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_greedy_bot_slays_the_wyrm() -> None:
|
||||
"""The single best end-to-end check: a short seed sweep KILLS THE WYRM.
|
||||
|
||||
If a greedy, unclever bot can take the Wyrm Below playing through the real
|
||||
façade, then the whole authored loop — movement, the zone-banded forest, the
|
||||
economy, the rung ladder, the satchel death-save, the forge, and the endgame
|
||||
gate — composes into a *winnable* game. A run that ever stops winning trips
|
||||
here. A small sweep (not one lucky seed) so the proof is robust.
|
||||
"""
|
||||
reports = [simulate(PACK, 40, seed) for seed in (1, 2, 3)]
|
||||
kills = [r for r in reports if r.wyrm_killed]
|
||||
assert kills, "the greedy bot never slew the Wyrm across the seed sweep"
|
||||
# Every kill records the day it first happened, within the run window.
|
||||
for r in kills:
|
||||
assert r.day_of_first_wyrm_kill is not None
|
||||
assert 1 <= r.day_of_first_wyrm_kill <= 40
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the bundled ALTERNATE world: The Cinder Wastes (LLM-authored from the manual)
|
||||
#
|
||||
# The Vale assertions above are the primary proof. These mirror them against the
|
||||
# real bundled second world, so the dogfood pack — authored cold from AUTHORING.md
|
||||
# — is held to the same bar: the bot must make genuine progress through it, and a
|
||||
# short seed sweep must actually slay its Magma Wyrm. If the authored world ever
|
||||
# stops being winnable, this trips.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CINDER = Path(__file__).resolve().parents[1] / "understone" / "world" / "packs" / "cinder-wastes"
|
||||
|
||||
|
||||
def test_cinder_wastes_bot_makes_progress() -> None:
|
||||
"""A short Cinder Wastes run climbs past level 1 and genuinely plays.
|
||||
|
||||
Fifteen days lands before the bot's first Wyrm kill (~day 24), so the level
|
||||
is still climbing rather than reset post-win — a stable "the world plays"
|
||||
signal across the durable measures (level, fights, gold, the rung ladder).
|
||||
"""
|
||||
r = simulate(CINDER, 15, 1)
|
||||
assert r.final_level > 1
|
||||
assert r.fights_fought > 0
|
||||
assert r.total_gold_earned > 0
|
||||
assert r.rungs_cleared > 0 # the caldera rung ladder is reachable
|
||||
|
||||
|
||||
def test_cinder_wastes_is_winnable() -> None:
|
||||
"""The dogfood proof: a greedy bot SLAYS THE MAGMA WYRM in the authored world.
|
||||
|
||||
The Cinder Wastes was written by an LLM working only from AUTHORING.md and
|
||||
the validator. This is the end-to-end demonstration that the manual plus the
|
||||
loader produce not merely a *valid* pack but a *playable-to-victory* one — a
|
||||
short seed sweep takes the Magma Wyrm. (It is harder than the Vale: the kill
|
||||
lands later, so the window is wider than the Vale's.)
|
||||
"""
|
||||
reports = [simulate(CINDER, 50, seed) for seed in (1, 2, 3)]
|
||||
kills = [r for r in reports if r.wyrm_killed]
|
||||
assert kills, "the greedy bot never slew the Magma Wyrm across the seed sweep"
|
||||
for r in kills:
|
||||
assert r.day_of_first_wyrm_kill is not None
|
||||
assert 1 <= r.day_of_first_wyrm_kill <= 50
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# robustness on non-shipped pack shapes: location doors inside hunt zones
|
||||
#
|
||||
# The bot runs arbitrary authored packs, not just the two bundled worlds, so a
|
||||
# zone may overlap a location door. A door cell is "walkable" (you can step onto
|
||||
# it) but standing on it flips the bot into that location's MENU — useless ground
|
||||
# for a forest fight, and a "fight" issued from a MENU is rejected by the engine
|
||||
# WITHOUT spending a turn. These pin the two guards that keep that from spinning
|
||||
# the per-day loop or over-counting fights.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _door(x: int, y: int) -> LocationDef:
|
||||
"""A bare location door placed at ``(x, y)`` (an inn, for concreteness)."""
|
||||
return LocationDef(
|
||||
key="inn",
|
||||
kind="inn",
|
||||
name="Wayhouse",
|
||||
x=x,
|
||||
y=y,
|
||||
glyph="⌂",
|
||||
color="town",
|
||||
actions=("rest", "leave"),
|
||||
)
|
||||
|
||||
|
||||
def test_nearest_in_zone_skips_a_door_cell() -> None:
|
||||
"""A door is never returned as a zone's hunt cell, even when it is nearest.
|
||||
|
||||
The zone here spans a column running away from the spawn; its closest-to-spawn
|
||||
walkable cell IS a location door, with open ground one step further. The
|
||||
helper must skip the door (it would only trap the bot in a menu) and return
|
||||
the open cell beyond it — the FIX-2 filter, mirroring ``_adjacent_open``.
|
||||
"""
|
||||
# 11x11 grass; spawn (5, 5). A door at (5, 6) is the nearest cell inside the
|
||||
# zone (Manhattan 1); the nearest OPEN in-zone cell is (5, 7) (Manhattan 2).
|
||||
world = make_world(
|
||||
locations=[_door(5, 6)],
|
||||
zones=[Zone(key="wood", x0=5, y0=6, x1=5, y1=9, tier_lo=1, tier_hi=1)],
|
||||
)
|
||||
walkable = sim._reachable(world)
|
||||
assert (5, 6) in walkable # the door cell is walkable...
|
||||
cell = sim._nearest_in_zone(world, walkable, world.zones[0])
|
||||
assert cell is not None
|
||||
assert cell != (5, 6) # ...but the helper does not pick it
|
||||
assert world.location_at(*cell) is None # the returned cell is open ground
|
||||
assert cell == (5, 7) # the nearest open in-zone cell beyond the door
|
||||
|
||||
|
||||
def test_zone_hunt_spots_drops_a_zone_with_no_fightable_foe() -> None:
|
||||
"""A zone whose tier band holds no foe is dropped, not appended with None.
|
||||
|
||||
FIX-4: the fallback in ``_best_hunt_spot`` (``ranked[-1]``) must never land on
|
||||
a zone where no monster can roll. A zone banded to a tier with no monster is
|
||||
simply not a hunting ground, so it never enters the spot list.
|
||||
"""
|
||||
# One zone banded to tier 9 (no monster lives there); the only monster is a
|
||||
# tier-1 rat. The empty-band zone must be dropped entirely.
|
||||
world = make_world(
|
||||
monsters=[make_monster(tier=1)],
|
||||
zones=[Zone(key="void", x0=4, y0=4, x1=6, y1=6, tier_lo=9, tier_hi=9)],
|
||||
)
|
||||
spots = sim._zone_hunt_spots(world, sim._reachable(world))
|
||||
assert spots == [] # the foe-less zone is not a spot
|
||||
|
||||
|
||||
def test_hunt_yields_the_turn_when_stuck_in_a_menu(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A hunt that ends in a MENU yields the turn instead of over-counting.
|
||||
|
||||
The defence-in-depth for FIX-1: should the bot ever reach the fight moment
|
||||
still inside a location MENU (a door swallowed the walk), the engine would
|
||||
REJECT the "fight" without spending a turn — and the old string-only check
|
||||
misread that reject as a won bout, over-counting and spinning the loop. The
|
||||
new mode pre-check must instead leave the menu and return False (yield), so no
|
||||
phantom fight is recorded and the day loop makes honest progress.
|
||||
"""
|
||||
# A door at (5, 4) inside a tier-1 zone. We inject this door cell as the hunt
|
||||
# spot directly — the pre-FIX-2 state where a door WAS the nearest in-zone
|
||||
# cell — so the guard, not the spot-selection filter, is what is under test.
|
||||
world = make_world(
|
||||
locations=[_door(5, 4)],
|
||||
zones=[Zone(key="wood", x0=4, y0=3, x1=6, y1=5, tier_lo=1, tier_hi=1)],
|
||||
monsters=[make_monster(tier=1)],
|
||||
)
|
||||
clock = sim._Clock(sim._SIM_START)
|
||||
game = Game(world, Store(tmp_path / "g.db"), clock=clock, rng=GameRNG(seed=1)) # type: ignore[arg-type]
|
||||
bot = sim._Bot(game, world, clock)
|
||||
game.join(bot.name)
|
||||
bot._hunt_spots = [(1, (5, 4), make_monster(tier=1))]
|
||||
player = game.players[bot.name]
|
||||
|
||||
# Model "a location door swallowed the walk": every navigation step ends with
|
||||
# the bot back inside the door's menu, so the hunt reaches its fight decision
|
||||
# still in MENU mode no matter how many times it tries to step clear — exactly
|
||||
# the trap the guard exists for (a single un-menu + re-walk cannot escape it).
|
||||
def _walk_into_door(_goal: tuple[int, int]) -> None:
|
||||
player.mode = Mode.MENU
|
||||
player.at_location = "inn"
|
||||
|
||||
monkeypatch.setattr(bot, "_goto_xy", _walk_into_door)
|
||||
_walk_into_door((5, 4)) # start the hunt already inside the menu
|
||||
|
||||
fought = bot._hunt()
|
||||
|
||||
assert fought is False # the turn is yielded, not spent on a menu-reject
|
||||
assert bot.fights_fought == 0 # no phantom fight recorded
|
||||
assert game.players[bot.name].mode is Mode.TILE # and the menu was left behind
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Understone — a BBS-style ANSI door game served over MCP."""
|
||||
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "0.8.0"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user