feat(examples): Understone v0.7 — the deep, the satchel, the forge, rare beasts

The depth slice: four standing reasons to return past the daily reset.

- The rung ladder: the dungeon is a descent fought one rung per turn, each
  guardian a fixed tier. A loss bounces you home but your depth PERSISTS —
  you re-enter where you left off. The Wyrm now gates on BOTH level AND
  reaching the floor (the deep has a bottom, and you must have touched it).
- The satchel + the death-save: potions are CARRIED now (up to three),
  bought to the satchel, drunk with quaff. The heart of it: when any fight
  would kill the active fighter and they carry a draught, the strongest is
  drunk automatically — they survive standing at the potion's value, no
  bounce. This fires on EVERY fight (forest, rung, and the Wyrm itself —
  a potion carried to the climax is a real tactical choice); a Wyrm loss
  so saved is "driven back, alive but unproven," not devoured. The sleeping
  ambush victim never quaffs (they are asleep). combat.py stays pure — the
  satchel and the save live entirely in the façade.
- The forge: the shop spends scaling gold to add a +1 edge to equipped
  weapon or armour, capped — the late-game gold sink. Swapping or selling
  the piece loses the edge with it (one centralized unequip clears the
  bonus and the plus so a stat can never go phantom).
- Rare beasts: a few named foes prowl the forest via weighted selection,
  surfacing seldom; felling one is a public Herald flash and always yields
  a draught into the satchel. Rung guardians are never rare (fixed foes).

Four new player columns; four new banded settings; dungeon_tiers extended
to three rungs. Tests 283 -> 330; the death-save (all four paths), forge
accounting across forge/buy/sell/legacy, rung math, and weighted rare
selection all pinned, with the death-save and forge invariants
revert-verified.
This commit is contained in:
Patrick Buckley
2026-06-12 20:21:12 -07:00
parent b76b2a98d0
commit dcc0e5fb0a
19 changed files with 1760 additions and 94 deletions
+16 -7
View File
@@ -37,14 +37,23 @@ A run is a little RPG loop, played a bit each day:
gold, a healing spring, a small trap (which can never kill you), or a scrap
of old Vale lore. Only one such find happens per move, and the non-combat
ones don't interrupt your walk.
- **Fight, shop, and heal** in and around town. Fighting and descending the
dungeon gauntlet each spend one of your daily turns; resting, shopping and
- **Fight, shop, and heal** in and around town. Fighting and descending one
rung of the dungeon each spend one of your daily turns; resting, shopping and
moving do not.
- **Delve the deep, a rung at a time.** The dungeon is a ladder of guardians:
each `descend` faces the next one past your deepest and either advances your
depth or bounces you home (your depth persists either way). Carry a few
**potions in your satchel**`quaff` the strongest when you choose, and if a
fight would kill you the satchel saves you automatically, the elixir burning
down your throat at death's edge. Spend gold at the shop's **forge** to add a
+1 edge to your gear, and watch for the **rare beasts** that prowl the forest:
felling one is Herald news and always drops a draught.
- **Win the game** by slaying **the Wyrm Below**. Once your hero is seasoned
enough, `challenge` it at the dungeon. A victory frees the Vale, carves your
run into the **Hall of Legends**, and — in the tradition of *Legend of the
Red Dragon* — begins a new life: your character resets to first-day gear and
stats but keeps a permanent ★ for every Wyrm slain, ready to do it all again.
enough AND has plumbed the deep to its floor, `challenge` it at the dungeon. A
victory frees the Vale, carves your run into the **Hall of Legends**, and — in
the tradition of the classic BBS door games — begins a new life: your
character resets to first-day gear and stats but keeps a permanent ★ for every
Wyrm slain, ready to do it all again.
- **Read the news.** `door_log` is the **Understone Herald**, a shared
broadsheet of notable deeds across the whole world — who joined, who rose a
level, who was dragged home by a goblin, and who freed the Vale.
@@ -217,7 +226,7 @@ url = "http://localhost:8077/mcp"
| `door_status` | The character sheet (read-only). |
| `door_look` | Redraw the current view — overworld map or location menu. |
| `door_move` | Walk the overworld (free; no daily turn spent). |
| `door_action` | Context verbs: fight, flee, ambush (a rival), rest, buy, sell, heal, gamble (inn dice), descend, challenge (the Wyrm), post (mail another player), leave. |
| `door_action` | Context verbs: fight, flee, ambush (a rival), rest, buy, sell, forge (a +1 edge), heal, gamble (inn dice), descend (one rung), challenge (the Wyrm), post (mail another player), quaff (a carried potion), leave. |
| `door_log` | The Understone Herald — the shared feed of notable deeds. |
| `door_rank` | The leaderboard, plus the Hall of Legends (★ marks Wyrm kills). |
| `door_bestow` | Game-master grant of a little gold/healing for a story beat. |
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "understone"
version = "0.6.0"
version = "0.7.0"
description = "Understone — a BBS-style ANSI door game served over MCP."
requires-python = ">=3.11"
license = "Apache-2.0"
+8
View File
@@ -64,6 +64,10 @@ DEFAULT_SETTINGS = Settings(
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="minor_potion",
)
@@ -93,6 +97,10 @@ def make_settings(**overrides: object) -> Settings:
"post_daily_cap": DEFAULT_SETTINGS.post_daily_cap,
"gamble_max_bet": DEFAULT_SETTINGS.gamble_max_bet,
"gamble_daily_cap": DEFAULT_SETTINGS.gamble_daily_cap,
"satchel_max": DEFAULT_SETTINGS.satchel_max,
"forge_base_cost": DEFAULT_SETTINGS.forge_base_cost,
"forge_max_plus": DEFAULT_SETTINGS.forge_max_plus,
"rare_drop_item": DEFAULT_SETTINGS.rare_drop_item,
}
base.update(overrides)
return Settings(**base) # type: ignore[arg-type]
+852
View File
@@ -0,0 +1,852 @@
"""The v0.7 "depth below" retention mechanics over the shipped world.
Drives the game façade against a temp store, a frozen clock, and a seeded RNG
to pin the four retention features and their interactions:
* the RUNG LADDER — descend advances ``deepest_rung`` one guardian at a time,
reaching the floor opens the Wyrm's door, and a loss mid-descent PRESERVES
the depth (you re-enter where you left off);
* the WYRM DEPTH GATE — the challenge is refused for a shallow hero with a
message distinct from the level gate, and the level gate is checked FIRST;
* the SATCHEL and THE DEATH-SAVE — buying a potion stows it, quaff drinks the
strongest, and a lethal loss with a potion in the bag is survived instead of
bounced (the central rule), proven on both the fight and the ambush path;
* the FORGE — a +1 edge raises the live stat and costs scaled gold, capped,
with EXACT accounting verified across forge -> buy -> sell;
* RARE BEASTS — a weighted pick surfaces them seldom, a rare kill heralds and
drops a draught, and a full satchel blocks the drop without losing the kill.
Negative-test discipline (THE DEATH-SAVE):
``test_death_save_negative_without_satchel_check`` documents the revert: with
the ``_death_save`` call removed from ``_apply_fight`` (so the lethal branch
always bounces), the same potion-carrying hero who survives in
``test_death_save_fight_survives_at_potion_value`` instead wakes at the spawn
at 1 HP with the potion UNSPENT. The implementer made that edit by hand,
observed the survive-test fail (player bounced, potion still carried, no
dramatic line), and restored the call. This pair is the standing regression.
"""
from __future__ import annotations
from collections import Counter
from pathlib import Path
import pytest
from tests.conftest import fixed_clock, make_monster, make_world, utc
from understone.engine.models import Mode, Zone
from understone.engine.rng import GameRNG
from understone.game import Game
from understone.persistence import Store
from understone.world.loader import load_world
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
@pytest.fixture
def clock() -> object:
return fixed_clock(utc(2026, 6, 12, 10, 0))
def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
world = load_world(PACK)
store = Store(tmp_path / "game.db")
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
def _strong_at_dungeon(game: Game, name: str) -> object:
"""Join *name*, make them unbeatable, and stand them in the dungeon menu."""
game.join(name)
player = game.players[name]
player.level, player.atk, player.def_ = 20, 200, 100
player.hp = player.max_hp = 500
player.mode = Mode.MENU
player.at_location = "dungeon"
return player
# ---------------------------------------------------------------------------
# A) The rung ladder
# ---------------------------------------------------------------------------
def test_descend_advances_one_rung_at_a_time(tmp_path: Path, clock: object) -> None:
"""Each descent fights the NEXT rung and advances depth by exactly one."""
game = _game(tmp_path, clock)
player = _strong_at_dungeon(game, "Delver")
out1 = game.action("Delver", "descend", "", "")
assert "Forest Wolf" in out1 # rung 1 = tier-3 guardian
assert player.deepest_rung == 1
out2 = game.action("Delver", "descend", "", "")
assert "Cave Troll" in out2 # rung 2 = tier-4 guardian
assert player.deepest_rung == 2
assert "Forest Wolf" not in out2 # a descent fights ONE rung, not a gauntlet
def test_reaching_the_floor_opens_the_wyrm_door(tmp_path: Path, clock: object) -> None:
"""Clearing the last rung narrates the Wyrm's door and unlocks the challenge."""
game = _game(tmp_path, clock)
player = _strong_at_dungeon(game, "Delver")
floor = len(game.world.settings.dungeon_tiers)
out = ""
for _ in range(floor):
out = game.action("Delver", "descend", "", "")
assert player.deepest_rung == floor
assert "wyrm's door" in out.lower()
assert "challenge" in out.lower()
def test_descend_at_bottom_costs_no_turn(tmp_path: Path, clock: object) -> None:
"""Already at the floor, descend points at the challenge and spends nothing."""
game = _game(tmp_path, clock)
player = _strong_at_dungeon(game, "Delver")
floor = len(game.world.settings.dungeon_tiers)
player.deepest_rung = floor
before_turns = player.turns_left
out = game.action("Delver", "descend", "", "")
assert "plumbed the deep" in out.lower()
assert "challenge" in out.lower()
assert player.turns_left == before_turns # no fight, no turn
assert player.deepest_rung == floor
def test_descend_costs_a_turn_and_refuses_when_spent(tmp_path: Path, clock: object) -> None:
"""A descent spends a daily turn; with none left it is refused like a fight."""
game = _game(tmp_path, clock)
player = _strong_at_dungeon(game, "Delver")
before = player.turns_left
game.action("Delver", "descend", "", "")
assert player.turns_left == before - 1
player.turns_left = 0
rung_before = player.deepest_rung
out = game.action("Delver", "descend", "", "")
assert "too weary" in out.lower()
assert player.deepest_rung == rung_before # no rung gained on a refusal
def test_lose_mid_descent_persists_depth(tmp_path: Path, clock: object) -> None:
"""A loss bounces to the spawn but PRESERVES the depth already earned."""
game = _game(tmp_path, clock)
player = _strong_at_dungeon(game, "Delver")
# Clear the first rung, then become weak so the second rung floors us.
game.action("Delver", "descend", "", "")
assert player.deepest_rung == 1
player.mode = Mode.MENU
player.at_location = "dungeon"
player.atk, player.def_, player.hp, player.max_hp = 1, 0, 5, 5
out = game.action("Delver", "descend", "", "")
assert player.hp == 1
assert (player.x, player.y) == game.world.spawn
assert player.mode is Mode.TILE
assert player.deepest_rung == 1 # the cleared rung is NOT lost
assert "Cave Troll" in out # we re-entered at rung 2 and fell there
def _doomed_descender(game: Game, name: str) -> object:
"""Stand *name* at rung 2 (deepest_rung == 1) and make the next rung lethal.
The next descent faces the Cave Troll (the tier-4 guardian). The stats —
weak atk, modest def, hp 30 below max_hp 60 — drag the loss out over many
counter-rounds rather than a one-shot: a GENUINE grinding lethal loss (the
death-save must not be vindicated by an artefact where no blow ever lands).
The starting hp (30) is none of the potion heal values (15/40/70), so a
death-save that sets hp to the potion's heal is unmistakable.
"""
game.join(name)
player = game.players[name]
player.deepest_rung = 1 # already cleared rung 1; next descent is rung 2
player.mode = Mode.MENU
player.at_location = "dungeon"
# Stand AWAY from the spawn so a death-save (which never moves the fighter)
# is distinguishable from the lose-bounce (which sends them to the spawn).
player.x, player.y = 35, 25
player.atk, player.def_, player.hp, player.max_hp = 2, 8, 30, 60
return player
def test_descend_loss_with_potion_survives_keeping_depth(tmp_path: Path, clock: object) -> None:
"""A lethal descent with a potion is SURVIVED standing, depth UNCHANGED.
The universal death-save reaches the deep: a draught in the satchel is drunk
instead of the spawn bounce. The hero keeps their place at the dungeon (no
bounce), hp set to the potion's heal, the potion spent, ``deepest_rung``
unchanged (the rung was not cleared, but the depth already earned stands),
the dramatic line spliced in, and the turn still spent.
"""
game = _game(tmp_path, clock)
player = _doomed_descender(game, "Delver")
potion = game.world.item_by_id("greater_potion")
assert potion is not None
game._satchel_set(player, ["greater_potion"])
spawn = game.world.spawn
before_turns = player.turns_left
before_events = len(game.events)
out = game.action("Delver", "descend", "", "")
assert "Cave Troll" in out # the genuine rung-2 lethal bout was fought
assert player.hp == min(player.max_hp, potion.heal) # stood at the potion's value
assert (player.x, player.y) != spawn # NOT bounced to the spawn
assert player.mode is Mode.MENU # still standing at the dungeon
assert player.deepest_rung == 1 # depth kept; the rung was not cleared
assert game._satchel_list(player) == [] # the draught was spent
assert "death's edge" in out.lower() # the spliced survival line
assert player.turns_left == before_turns - 1 # the descent still cost a turn
# A death-save is not a defeat: no public "defeat" beat was heralded.
new = game.events[before_events:]
assert all(e.kind != "defeat" for e in new)
def test_descend_loss_without_potion_bounces_as_before(tmp_path: Path, clock: object) -> None:
"""Without a potion, the same lethal descent bounces to the spawn, depth kept.
The companion to the survival test (and the standing negative for the deep
save): an empty satchel falls through ``_death_save`` to the standard spawn
bounce — 1 HP at the spawn, ``deepest_rung`` preserved, a public defeat beat.
"""
game = _game(tmp_path, clock)
player = _doomed_descender(game, "Delver") # empty satchel
spawn = game.world.spawn
before_events = len(game.events)
out = game.action("Delver", "descend", "", "")
assert "Cave Troll" in out
assert player.hp == 1 # bounced, not saved
assert (player.x, player.y) == spawn
assert player.mode is Mode.TILE
assert player.deepest_rung == 1 # the cleared rung is still NOT lost
assert "death's edge" not in out.lower() # no save, no dramatic line
new = game.events[before_events:]
assert any(e.kind == "defeat" for e in new) # the defeat beat fired
def test_descend_progress_in_status(tmp_path: Path, clock: object) -> None:
"""door_status shows the deep progress as 'rung N/total'."""
game = _game(tmp_path, clock)
_strong_at_dungeon(game, "Delver")
floor = len(game.world.settings.dungeon_tiers)
game.action("Delver", "descend", "", "")
out = game.status("Delver")
assert f"rung 1/{floor}" in out
# ---------------------------------------------------------------------------
# B) The Wyrm depth gate (distinct from the level gate; level checked first)
# ---------------------------------------------------------------------------
def test_challenge_refused_when_shallow_with_depth_message(tmp_path: Path, clock: object) -> None:
"""A high-level but shallow hero is refused with the DEPTH message, no turn."""
game = _game(tmp_path, clock)
game.join("Hero")
player = game.players["Hero"]
player.level = game.world.settings.wyrm_min_level # clears the level gate
player.deepest_rung = 0 # but has not plumbed the deep
player.mode = Mode.MENU
player.at_location = "dungeon"
before_turns = player.turns_left
before_events = len(game.events)
out = game.action("Hero", "challenge", "", "")
assert "plumbed the deep" in out.lower()
assert "circle" not in out.lower() # NOT the level-gate phrasing
assert player.turns_left == before_turns # no turn spent
assert len(game.events) == before_events # no public beat
assert player.mode is Mode.MENU # still at the dungeon
def test_level_gate_precedes_depth_gate(tmp_path: Path, clock: object) -> None:
"""A low-level shallow hero hears the LEVEL message, not the depth one."""
game = _game(tmp_path, clock)
game.join("Greenhorn")
player = game.players["Greenhorn"]
assert player.level < game.world.settings.wyrm_min_level
player.deepest_rung = 0 # also shallow
player.mode = Mode.MENU
player.at_location = "dungeon"
out = game.action("Greenhorn", "challenge", "", "")
assert "circle" in out.lower() # the level gate fires first
assert "plumbed the deep" not in out.lower()
def test_challenge_allowed_at_level_and_floor(tmp_path: Path, clock: object) -> None:
"""At the level gate AND the deep floor, the challenge proceeds (spends a turn)."""
game = _game(tmp_path, clock)
game.join("Champion")
player = game.players["Champion"]
player.level = game.world.settings.wyrm_min_level
player.deepest_rung = len(game.world.settings.dungeon_tiers) # at the floor
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
player.mode = Mode.MENU
player.at_location = "dungeon"
before_turns = player.turns_left
out = game.action("Champion", "challenge", "", "")
assert "circle" not in out.lower()
assert "plumbed the deep" not in out.lower()
assert player.turns_left == before_turns - 1 # the challenge ran
def test_legacy_reset_clears_depth(tmp_path: Path, clock: object) -> None:
"""Slaying the Wyrm resets deepest_rung to 0 — the reborn hero earns it again."""
game = _game(tmp_path, clock)
game.join("Champion")
player = game.players["Champion"]
player.level = game.world.settings.wyrm_min_level
player.deepest_rung = len(game.world.settings.dungeon_tiers)
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
player.mode = Mode.MENU
player.at_location = "dungeon"
game.action("Champion", "challenge", "", "")
assert player.wins == 1
assert player.deepest_rung == 0 # the deep must be plumbed anew
# ---------------------------------------------------------------------------
# C) The satchel: buy -> stow, the cap, and quaff
# ---------------------------------------------------------------------------
def _at_shop(game: Game, name: str) -> object:
game.join(name)
player = game.players[name]
player.mode = Mode.MENU
player.at_location = "shop"
return player
def test_buy_potion_stows_into_satchel(tmp_path: Path, clock: object) -> None:
"""Buying a consumable adds it to the satchel and spends the gold."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Buyer")
player.gold = 100
item = game.world.item_by_id("minor_potion")
assert item is not None
out = game.action("Buyer", "buy", "", "minor_potion")
assert "satchel" in out.lower()
assert game._satchel_list(player) == ["minor_potion"]
assert player.gold == 100 - item.price
# The potion was NOT applied on buy (HP unchanged from full).
assert player.hp == player.max_hp
def test_satchel_cap_refuses_without_spending(tmp_path: Path, clock: object) -> None:
"""A full satchel refuses another draught and spends no gold."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Buyer")
cap = game.world.settings.satchel_max
player.gold = 10000
for _ in range(cap):
game.action("Buyer", "buy", "", "minor_potion")
assert len(game._satchel_list(player)) == cap
gold_at_cap = player.gold
out = game.action("Buyer", "buy", "", "minor_potion")
assert "bulges" in out.lower()
assert len(game._satchel_list(player)) == cap # still full, not over
assert player.gold == gold_at_cap # the refused buy cost nothing
def test_quaff_drinks_strongest_and_caps_at_max_hp(tmp_path: Path, clock: object) -> None:
"""quaff drinks the highest-heal potion, heals, caps at max_hp, removes it."""
game = _game(tmp_path, clock)
game.join("Drinker")
player = game.players["Drinker"]
# Carry a weak and a strong potion; the strong one must be chosen.
game._satchel_set(player, ["minor_potion", "greater_potion"])
player.max_hp = 100
player.hp = 90 # greater_potion heals 40, but the cap clamps the gain to 10
out = game.action("Drinker", "quaff", "", "")
assert "greater potion" in out.lower() # the STRONGEST was drunk
assert player.hp == 100 # capped at max_hp
assert game._satchel_list(player) == ["minor_potion"] # only the strong one left
def test_quaff_empty_satchel_refuses(tmp_path: Path, clock: object) -> None:
"""quaff with an empty satchel is a friendly refusal."""
game = _game(tmp_path, clock)
game.join("Drinker")
out = game.action("Drinker", "quaff", "", "")
assert "satchel is empty" in out.lower()
def test_quaff_at_full_hp_refuses_and_keeps_potion(tmp_path: Path, clock: object) -> None:
"""At full HP, quaff refuses rather than waste the draught."""
game = _game(tmp_path, clock)
game.join("Drinker")
player = game.players["Drinker"]
game._satchel_set(player, ["minor_potion"])
assert player.hp == player.max_hp
out = game.action("Drinker", "quaff", "", "")
assert "already hale" in out.lower()
assert game._satchel_list(player) == ["minor_potion"] # not wasted
def test_satchel_listed_in_status(tmp_path: Path, clock: object) -> None:
"""door_status lists the satchel contents."""
game = _game(tmp_path, clock)
game.join("Drinker")
player = game.players["Drinker"]
game._satchel_set(player, ["minor_potion"])
out = game.status("Drinker")
assert "satchel" in out.lower()
assert "Minor Potion" in out
# ---------------------------------------------------------------------------
# D) THE DEATH-SAVE (the central rule; negative-tested below)
# ---------------------------------------------------------------------------
def _doomed_fighter(game: Game, name: str) -> object:
"""Join *name*, stand them in a forest, and make a fight certain to kill."""
game.join(name)
player = game.players[name]
player.x, player.y = 35, 25 # forest_near zone
player.atk, player.def_, player.hp, player.max_hp = 1, 0, 2, 60
return player
def test_death_save_fight_survives_at_potion_value(tmp_path: Path, clock: object) -> None:
"""A lethal fight with a potion in the satchel is SURVIVED, not bounced.
See the module docstring for the revert-and-observe-failure check (paired
with ``test_death_save_negative_without_satchel_check``).
"""
game = _game(tmp_path, clock)
player = _doomed_fighter(game, "Doomed")
potion = game.world.item_by_id("greater_potion")
assert potion is not None
game._satchel_set(player, ["greater_potion"])
spawn = game.world.spawn
before_events = len(game.events)
out = game.action("Doomed", "fight", "", "")
# Survived standing: hp set to the potion's heal (clamped to max_hp), NO
# spawn bounce, the potion consumed, and the dramatic line present.
assert player.hp == min(player.max_hp, potion.heal)
assert (player.x, player.y) != spawn # never moved to the spawn
assert game._satchel_list(player) == [] # the draught was spent
assert "death's edge" in out.lower() # the spliced line
# A death-save is NOT a defeat: no public "defeat" beat was heralded.
new = game.events[before_events:]
assert all(e.kind != "defeat" for e in new)
def test_death_save_negative_without_satchel_check(
tmp_path: Path, clock: object, monkeypatch: pytest.MonkeyPatch
) -> None:
"""NEGATIVE TEST: with the satchel check disabled, the same fight BOUNCES.
This is the mechanical equivalent of reverting the ``_death_save`` call: we
stub ``_death_save`` to always decline, then run the exact scenario of the
survive-test. The hero must now wake at the spawn at 1 HP with the potion
UNSPENT — proving the death-save (not some other path) is what saves them.
Restoring the real method (automatic when the patch lifts) restores the
survival behaviour.
"""
game = _game(tmp_path, clock)
player = _doomed_fighter(game, "Doomed")
game._satchel_set(player, ["greater_potion"])
spawn = game.world.spawn
monkeypatch.setattr(Game, "_death_save", lambda self, pl, lines: False)
out = game.action("Doomed", "fight", "", "")
assert player.hp == 1 # bounced, not saved
assert (player.x, player.y) == spawn
assert game._satchel_list(player) == ["greater_potion"] # potion NOT spent
assert "death's edge" not in out.lower() # no dramatic line
def test_death_save_uses_strongest_potion(tmp_path: Path, clock: object) -> None:
"""The death-save spends the STRONGEST carried potion, leaving the rest."""
game = _game(tmp_path, clock)
player = _doomed_fighter(game, "Doomed")
elixir = game.world.item_by_id("elixir_of_the_vale")
assert elixir is not None
game._satchel_set(player, ["minor_potion", "elixir_of_the_vale"])
game.action("Doomed", "fight", "", "")
assert player.hp == min(player.max_hp, elixir.heal) # the elixir, not the minor
assert game._satchel_list(player) == ["minor_potion"] # the weak one remains
def test_ambush_attacker_death_save(tmp_path: Path, clock: object) -> None:
"""A lethal ambush counter-strike is survived if the ATTACKER carries a potion."""
game = _game(tmp_path, clock)
game.join("Robber")
game.join("Sleeper")
settings = game.world.settings
attacker = game.players["Robber"]
victim = game.players["Sleeper"]
# Both eligible and near in level; the sleeper is a deadly wake-up.
attacker.level = victim.level = settings.ambush_min_level
attacker.atk, attacker.def_, attacker.hp, attacker.max_hp = 1, 0, 2, 60
# Stand the attacker AWAY from the spawn so a death-save (which never moves
# the fighter) is distinguishable from the lose-bounce (which sends them to
# the spawn). Both heroes are level-3+ and near in level, so the ambush is
# legal; the field tile is unmanned so the band/sleep checks still pass.
attacker.x, attacker.y = 35, 25
victim.atk, victim.def_, victim.hp = 50, 0, 30
victim.turn_day = 0 # asleep (has not acted today)
potion = game.world.item_by_id("greater_potion")
assert potion is not None
game._satchel_set(attacker, ["greater_potion"])
spawn = game.world.spawn
game.action("Robber", "ambush", "Sleeper", "")
assert attacker.hp == min(attacker.max_hp, potion.heal) # saved
assert (attacker.x, attacker.y) != spawn # not bounced — stood their ground
assert game._satchel_list(attacker) == [] # potion spent
def test_ambush_victim_never_quaffs(tmp_path: Path, clock: object) -> None:
"""The sleeping ambush VICTIM never auto-quaffs — they are asleep.
Even with a potion in the victim's satchel, a winning ambush robs them and
drops them to 1 HP at the spawn; their draught is untouched.
"""
game = _game(tmp_path, clock)
game.join("Robber")
game.join("Sleeper")
settings = game.world.settings
attacker = game.players["Robber"]
victim = game.players["Sleeper"]
attacker.level = victim.level = settings.ambush_min_level
attacker.atk, attacker.def_, attacker.hp, attacker.max_hp = 200, 100, 500, 500
victim.atk, victim.def_, victim.hp = 1, 0, 3
victim.gold = 100
victim.turn_day = 0 # asleep
game._satchel_set(victim, ["greater_potion"])
game.action("Robber", "ambush", "Sleeper", "")
assert victim.hp == 1 # robbed and floored, NOT death-saved
assert (victim.x, victim.y) == game.world.spawn
assert game._satchel_list(victim) == ["greater_potion"] # the sleeper's potion is untouched
# ---------------------------------------------------------------------------
# E) The forge (exact accounting across forge -> buy -> sell)
# ---------------------------------------------------------------------------
def test_forge_plus_one_raises_stat_and_costs_scaled_gold(tmp_path: Path, clock: object) -> None:
"""forge +1 raises atk by 1 and costs base*(0+1); +2 costs base*(1+1)."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
base = game.world.settings.forge_base_cost
player.gold = 10000
atk0 = player.atk
out1 = game.action("Smith", "forge", "weapon", "")
assert player.weapon_plus == 1
assert player.atk == atk0 + 1
assert "+1" in out1
after_first = player.gold
assert after_first == 10000 - base * 1 # base * (0 + 1)
out2 = game.action("Smith", "forge", "weapon", "")
assert player.weapon_plus == 2
assert player.atk == atk0 + 2
assert "+2" in out2
assert player.gold == after_first - base * 2 # base * (1 + 1), dearer
def test_forge_armor_raises_def(tmp_path: Path, clock: object) -> None:
"""forge armour raises def_ by one per tier."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 10000
def0 = player.def_
game.action("Smith", "forge", "armour", "")
assert player.armor_plus == 1
assert player.def_ == def0 + 1
def test_forge_caps_at_max_plus(tmp_path: Path, clock: object) -> None:
"""At the forge cap, a further forge is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
cap = game.world.settings.forge_max_plus
player.gold = 100000
for _ in range(cap):
game.action("Smith", "forge", "weapon", "")
assert player.weapon_plus == cap
atk_at_cap, gold_at_cap = player.atk, player.gold
out = game.action("Smith", "forge", "weapon", "")
assert "no finer edge" in out.lower()
assert player.weapon_plus == cap # not over the cap
assert player.atk == atk_at_cap # no stat change
assert player.gold == gold_at_cap # no gold spent
def test_forge_unaffordable_refuses(tmp_path: Path, clock: object) -> None:
"""A hero who cannot pay the forge price is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 1 # far below the base cost
atk0 = player.atk
out = game.action("Smith", "forge", "weapon", "")
assert player.weapon_plus == 0
assert player.atk == atk0
assert player.gold == 1
assert "gold" in out.lower()
def test_forge_invalid_target_is_friendly(tmp_path: Path, clock: object) -> None:
"""A missing/unknown forge target asks which slot, without mutation."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 10000
out = game.action("Smith", "forge", "", "")
assert "weapon" in out.lower() and "armour" in out.lower()
assert player.weapon_plus == 0 and player.armor_plus == 0
def test_forge_then_buy_zeroes_plus_and_removes_phantom_atk(tmp_path: Path, clock: object) -> None:
"""Buying a new weapon after forging zeroes the plus AND removes phantom atk.
The bug-prone path: a +2 blade's enhancement rode the OLD weapon. Swapping
to a new blade must subtract the old base bonus AND the old +2, then equip
the new base bonus — leaving atk exactly (unenhanced new weapon), with
weapon_plus back to 0. Verified by reconstructing the expected atk from the
base bonuses alone.
"""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 100000
# Establish a known starting point: equip the short sword fresh.
starter = game.world.item_by_id(game.world.settings.starting_weapon)
short = game.world.item_by_id("short_sword")
iron = game.world.item_by_id("iron_sword")
assert starter is not None and short is not None and iron is not None
base_human = game.world.settings.start_atk # un-weaponed atk baseline
game.action("Smith", "buy", "", "short_sword")
assert player.weapon_id == "short_sword"
assert player.weapon_plus == 0
assert player.atk == base_human + short.atk
# Forge the short sword to +2.
game.action("Smith", "forge", "weapon", "")
game.action("Smith", "forge", "weapon", "")
assert player.weapon_plus == 2
assert player.atk == base_human + short.atk + 2
# Buy the iron sword: the +2 must vanish with the short sword.
game.action("Smith", "buy", "", "iron_sword")
assert player.weapon_id == "iron_sword"
assert player.weapon_plus == 0 # the new blade is unenhanced
assert player.atk == base_human + iron.atk # NO phantom +2 left behind
def test_forge_then_sell_zeroes_plus_and_removes_phantom_atk(tmp_path: Path, clock: object) -> None:
"""Selling a forged weapon zeroes its plus and removes the phantom atk.
A +1 short sword sold back must drop the short sword's base bonus AND the
+1, falling to the starter blade with weapon_plus at 0 — exact accounting.
"""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 100000
starter = game.world.item_by_id(game.world.settings.starting_weapon)
short = game.world.item_by_id("short_sword")
assert starter is not None and short is not None
base_human = game.world.settings.start_atk
game.action("Smith", "buy", "", "short_sword")
game.action("Smith", "forge", "weapon", "")
assert player.weapon_plus == 1
assert player.atk == base_human + short.atk + 1
game.action("Smith", "sell", "", "")
assert player.weapon_id == game.world.settings.starting_weapon
assert player.weapon_plus == 0 # the slot's enhancement is gone with the blade
assert player.atk == base_human + starter.atk # back to the starter, no phantom
def test_forge_plus_shown_in_status(tmp_path: Path, clock: object) -> None:
"""door_status shows the forged plus on the equipped weapon name."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 100000
game.action("Smith", "buy", "", "iron_sword")
game.action("Smith", "forge", "weapon", "")
game.action("Smith", "forge", "weapon", "")
out = game.status("Smith")
assert "Iron Sword +2" in out
# ---------------------------------------------------------------------------
# F) Rare named monsters (weighted pick + Herald flash + guaranteed draught)
# ---------------------------------------------------------------------------
def test_rare_kill_heralds_and_drops_draught(tmp_path: Path, clock: object) -> None:
"""Killing a rare emits a public rare_kill beat AND drops a draught.
Seeds are scanned to land a Gilded Stag (the tier-2 rare) encounter, then
the kill is checked for the Herald flash and the satchel drop.
"""
game = _game(tmp_path, clock)
game.join("Hunter")
player = game.players["Hunter"]
player.x, player.y = 35, 25 # forest_near (holds the Gilded Stag)
player.atk, player.def_, player.hp, player.max_hp = 200, 100, 500, 500
drop_id = game.world.settings.rare_drop_item
before_events = len(game.events)
# Walk until the weighted pick surfaces the rare (it is weight 1, so seldom).
out = ""
for _ in range(400):
player.turns_left = 5 # keep a turn available
out = game.action("Hunter", "fight", "", "")
if "Gilded Stag" in out and "falls" in out:
break
else: # pragma: no cover - the loop is expected to find a rare
pytest.fail("no Gilded Stag encounter surfaced in 400 seeded fights")
# The actor's own frame carries the guaranteed-drop line; the "A rare ...
# has fallen" flash is a PUBLIC Herald beat (it rides the feed, not the
# fighter's reply), so it is asserted on the event log below.
assert "satchel" in out.lower() # the draught dropped
assert drop_id in game._satchel_list(player)
new = game.events[before_events:]
assert any(e.kind == "rare_kill" for e in new)
rare_beat = next(e for e in new if e.kind == "rare_kill")
assert "rare" in rare_beat.text.lower()
assert "Gilded Stag" in rare_beat.text and "Hunter" in rare_beat.text
def test_rare_kill_full_satchel_blocks_drop_but_kill_lands(tmp_path: Path, clock: object) -> None:
"""A full satchel blocks the rare drop, but the kill (xp/gold/herald) still lands."""
game = _game(tmp_path, clock)
game.join("Hunter")
player = game.players["Hunter"]
player.x, player.y = 35, 25
player.atk, player.def_, player.hp, player.max_hp = 200, 100, 500, 500
cap = game.world.settings.satchel_max
game._satchel_set(player, ["minor_potion"] * cap) # bag already full
before_events = len(game.events)
out = ""
for _ in range(400):
player.turns_left = 5
before_gold = player.gold
out = game.action("Hunter", "fight", "", "")
if "Gilded Stag" in out and "falls" in out:
break
else: # pragma: no cover
pytest.fail("no Gilded Stag encounter surfaced in 400 seeded fights")
assert "no room" in out.lower() # the drop was blocked
assert len(game._satchel_list(player)) == cap # still exactly full
assert player.gold > before_gold # the kill still paid out
new = game.events[before_events:]
assert any(e.kind == "rare_kill" for e in new) # and still heralded
def test_rung_guardian_is_the_fixed_band_zero_never_the_rare(tmp_path: Path, clock: object) -> None:
"""A rung is the fixed band[0] guardian, never the tier's rare beast.
The rung foe is deterministic (band[0]), independent of the RNG: every
dungeon tier's first non-boss monster is non-rare, and descending the first
rung always faces the Forest Wolf rather than the tier-3 rare (the Hollow
Knight) — the rare only surfaces through the WEIGHTED forest pick.
"""
game = _game(tmp_path, clock)
world = game.world
for tier in world.settings.dungeon_tiers:
guardian = world.monsters_for_tier_band(tier, tier)[0]
assert not guardian.rare, f"rung tier {tier} guardian {guardian.name!r} is rare"
# Descending the first rung faces the fixed guardian, never the tier-3 rare,
# regardless of seed (band[0] is not a weighted roll). Each seed gets its
# own DB file so the runs are independent.
for seed in (1, 7, 13, 42, 99):
sub_dir = tmp_path / f"seed_{seed}"
sub_dir.mkdir()
sub = _game(sub_dir, clock, seed=seed)
player = _strong_at_dungeon(sub, "Delver")
out = sub.action("Delver", "descend", "", "")
assert "Forest Wolf" in out
assert "Hollow Knight" not in out
assert player.deepest_rung == 1
sub.store.close()
def test_weighted_pick_surfaces_common_far_more_than_rare(tmp_path: Path, clock: object) -> None:
"""The weighted forest pick draws a common foe far more often than a rare.
A crafted single-tier band — a weight-10 "Common Beast" and a weight-1
"Rare Beast" — is sampled many times through the façade's ``_pick_monster``
under a seeded RNG. Over the sample the common must dominate roughly 10:1,
and BOTH must appear (the rare surfaces, just seldom). This pins that the
weighted draw (not a flat uniform pick) governs random encounters.
"""
common = make_monster(name="Common Beast", tier=2, weight=10, rare=False)
rare = make_monster(name="Rare Beast", tier=2, weight=1, rare=True)
zone = Zone(key="wood", x0=0, y0=0, x1=10, y1=10, tier_lo=2, tier_hi=2)
world = make_world(monsters=[common, rare], zones=[zone])
store = Store(tmp_path / "weighted.db")
game = Game(world, store, clock=clock, rng=GameRNG(seed=7)) # type: ignore[arg-type]
player = _join_at(game, "Forager", 5, 5)
counts: Counter[str] = Counter()
for _ in range(2000):
picked = game._pick_monster(player)
assert picked is not None
counts[picked.name] += 1
assert counts["Common Beast"] > 0 and counts["Rare Beast"] > 0 # both surface
# The common is ~10x the rare; a generous 4x floor keeps the test stable
# under RNG variance while still proving the weighting is in force.
assert counts["Common Beast"] > counts["Rare Beast"] * 4
store.close()
def _join_at(game: Game, name: str, x: int, y: int) -> object:
"""Join *name* and place them at (x, y) on the overworld (test helper)."""
game.join(name)
player = game.players[name]
player.x, player.y = x, y
return player
+15 -10
View File
@@ -632,12 +632,12 @@ def test_bestow_heal_only_at_full_hp_refused(tmp_path: Path, clock: object) -> N
# ---------------------------------------------------------------------------
# Descend gauntlet: survive both tiers, or bounce to spawn on the first
# Descend the deep: one rung per descent (see test_descend.py for the ladder)
# ---------------------------------------------------------------------------
def test_descend_survives_full_gauntlet(tmp_path: Path, clock: object) -> None:
"""A strong player clears both tiers: two foes fought, rewards banked."""
def test_descend_fights_one_rung_and_advances(tmp_path: Path, clock: object) -> None:
"""A strong player clears the next rung: one foe fought, rewards banked, depth +1."""
game = _game(tmp_path, clock)
game.join("Hero")
player = game.players["Hero"]
@@ -649,16 +649,21 @@ def test_descend_survives_full_gauntlet(tmp_path: Path, clock: object) -> None:
out = game.action("Hero", "descend", "", "")
# Both ladder rungs were fought (the tier-4 and tier-5 boss names appear).
assert "Cave Troll" in out
assert "Stone Wyrm" in out
# The first rung is the tier-3 guardian (Forest Wolf); deeper rungs do NOT
# appear in one descent — the deep is fought a rung at a time now.
assert "Forest Wolf" in out
assert "Cave Troll" not in out
assert player.deepest_rung == 1
assert player.turns_left == before_turns - 1
assert player.gold > before_gold
assert player.xp > before_xp
def test_descend_bounces_weak_player_to_spawn(tmp_path: Path, clock: object) -> None:
"""A fresh weak player falls on the first foe and wakes at the spawn."""
"""A fresh weak player falls on the first rung and wakes at the spawn.
Depth is NOT advanced by a loss, but it persists at whatever it was (here 0).
"""
game = _game(tmp_path, clock)
game.join("Weakling")
player = game.players["Weakling"]
@@ -671,9 +676,9 @@ def test_descend_bounces_weak_player_to_spawn(tmp_path: Path, clock: object) ->
assert player.mode is Mode.TILE
assert player.at_location == ""
assert (player.x, player.y) == game.world.spawn
# Felled by the first rung; the second boss never appears.
assert "Cave Troll" in out
assert "Stone Wyrm" not in out
assert player.deepest_rung == 0 # a loss never advances the deep
# Felled by the first rung (the tier-3 Forest Wolf).
assert "Forest Wolf" in out
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -6,4 +6,4 @@ import understone
def test_version_present() -> None:
assert understone.__version__ == "0.6.0"
assert understone.__version__ == "0.7.0"
@@ -205,3 +205,59 @@ def test_meta_round_trip(tmp_path: Path) -> None:
assert store.get_meta("world_name") == "The Vale of Understone"
assert store.get_meta("missing") is None
store.close()
def test_v0_7_depth_columns_round_trip(tmp_path: Path) -> None:
"""The four v0.7 columns survive a store reopen: depth, satchel, two plusses."""
store = _store(tmp_path)
player = make_player(
name="Delver",
deepest_rung=2,
satchel="minor_potion,greater_potion",
weapon_plus=2,
armor_plus=1,
)
store.upsert_player(player)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
loaded = players["Delver"]
assert loaded == player # full equality across every column
assert loaded.deepest_rung == 2
assert loaded.satchel == "minor_potion,greater_potion"
assert loaded.weapon_plus == 2
assert loaded.armor_plus == 1
reopened.close()
def test_v0_7_depth_columns_default_for_legacy_rows(tmp_path: Path) -> None:
"""A row written without the new columns loads them at their defaults.
The schema mutates in place (no migration, stamp stays 1), so the new
columns carry DB-side defaults: a pre-v0.7 player row (inserted with the
legacy column set) must read back deepest_rung 0, an empty satchel, and
zero plusses rather than erroring.
"""
store = _store(tmp_path)
store._conn.execute(
"INSERT INTO players "
"(name, x, y, hp, max_hp, level, xp, gold, atk, def_, weapon_id, armor_id, "
" turns_left, turn_day, mode, at_location, created_at, last_seen, log_cursor, "
" bestow_spent, bestow_day) "
"VALUES ('Old', 5, 5, 20, 20, 1, 0, 20, 5, 1, 'rusty_dagger', 'cloth_tunic', "
" 10, 0, 'tile', '', 't0', 't0', 0, 0, 0)",
)
store.commit()
store.close()
reopened = _store(tmp_path)
players, _ = reopened.load_all()
old = players["Old"]
assert old.deepest_rung == 0
assert old.satchel == ""
assert old.weapon_plus == 0
assert old.armor_plus == 0
assert reopened.get_meta("schema_version") == "1" # stamp unchanged
reopened.close()
@@ -547,3 +547,113 @@ def test_overlong_monster_name_rejected(tmp_path: Path) -> None:
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] name is 49 characters"):
load_world(pack)
# ---------------------------------------------------------------------------
# v0.7 loader rejections: the satchel/forge bands, rare_drop_item, monster weight
# ---------------------------------------------------------------------------
def test_rare_drop_item_unknown_rejected(tmp_path: Path) -> None:
"""A rare_drop_item that names no item is rejected with the item-id message."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["rare_drop_item"] = "no_such_draught"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="rare_drop_item = 'no_such_draught' is not a known"):
load_world(pack)
def test_rare_drop_item_non_consumable_rejected(tmp_path: Path) -> None:
"""A rare_drop_item that names a weapon (not a consumable) is rejected.
The drop goes straight into the satchel to be quaffed, so a weapon or
armour id is incoherent — the loader pins the slot.
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["rare_drop_item"] = "iron_sword" # a weapon, not a draught
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="rare_drop_item = 'iron_sword' must be a consumable"):
load_world(pack)
def test_satchel_max_out_of_band_rejected(tmp_path: Path) -> None:
"""satchel_max above its 1..10 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["satchel_max"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"satchel_max = 11 is out of band \(1\.\.10\)"):
load_world(pack)
def test_forge_max_plus_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_max_plus above its 0..10 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_max_plus"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_max_plus = 11 is out of band \(0\.\.10\)"):
load_world(pack)
def test_forge_base_cost_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_base_cost below its floor of 1 is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_base_cost"] = 0
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_base_cost = 0 is out of band \(1\.\.10000\)"):
load_world(pack)
def test_monster_zero_weight_rejected(tmp_path: Path) -> None:
"""A monster weight of 0 is rejected (the weighted pick needs a positive total)."""
pack = _clone_pack(tmp_path)
def mutate(data: list[dict[str, Any]]) -> None:
data[0]["weight"] = 0
_rewrite(pack / "monsters.json", mutate)
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] weight must be > 0"):
load_world(pack)
def test_shipped_pack_carries_rares_and_weights() -> None:
"""The shipped pack parses the v0.7 rare beasts with their low weights."""
world = load_world(SHIPPED)
rares = [m for m in world.monsters if m.rare]
names = {m.name for m in rares}
assert names == {"the Gilded Stag", "the Hollow Knight"}
assert all(m.weight == 1 for m in rares) # rares surface seldom
# The rare_drop_item resolves to a consumable.
drop = world.item_by_id(world.settings.rare_drop_item)
assert drop is not None and drop.slot.value == "consumable"
# The new economy settings land on their shipped values.
assert world.settings.satchel_max == 3
assert world.settings.forge_base_cost == 60
assert world.settings.forge_max_plus == 3
assert world.settings.dungeon_tiers == (3, 4, 5)
def test_monster_weight_and_rare_default_when_omitted(tmp_path: Path) -> None:
"""A monster spec without weight/rare loads as weight 10, rare False.
Both fields are optional with defaults, so an unannotated common monster
(the shipped Field Rat) parses to the default weight and the non-rare flag.
"""
world = load_world(SHIPPED)
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
+100 -1
View File
@@ -42,10 +42,18 @@ def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
def _at_dungeon(game: Game, name: str) -> object:
"""Place an already-joined player inside the dungeon menu."""
"""Place an already-joined player inside the dungeon menu, at the deep floor.
The challenge verb now gates on depth as well as level: the Wyrm will not
stir until the hero has plumbed the deep to its floor. These challenge
tests exercise the win/lose/flee paths, not the gate, so the helper puts
the hero at the bottom (deepest_rung == the rung count). The depth gate
itself is exercised by the dedicated tests in test_descend.py.
"""
player = game.players[name]
player.mode = Mode.MENU
player.at_location = "dungeon"
player.deepest_rung = len(game.world.settings.dungeon_tiers)
return player
@@ -266,6 +274,97 @@ def test_challenge_loss_bounces_and_heralds(tmp_path: Path, clock: object) -> No
assert "lays you low" in out.lower() or "wyrm" in out.lower()
def _doomed_wyrm_challenger(game: Game, name: str) -> object:
"""Stand *name* at the floor, wyrm-eligible, and doomed to a GRINDING loss.
The stats — modest atk and def, hp 50 below max_hp 80, well off the spawn —
make the Wyrm bout a genuine multi-round lethal loss (not a one-shot where
no blow lands before the save). hp 50 is none of the potion heal values
(15/40/70), so a death-save that sets hp to the potion's heal is unmistakable.
"""
player = _at_dungeon(game, name)
player.level = game.world.settings.wyrm_min_level
player.x, player.y = 35, 25 # away from the spawn (a save never moves them)
player.atk, player.def_, player.hp, player.max_hp = 6, 12, 50, 80
return player
def test_challenge_loss_with_potion_survives_no_legacy_reset(tmp_path: Path, clock: object) -> None:
"""A lethal Wyrm bout with a potion is SURVIVED — no bounce, no legacy reset.
The universal death-save reaches the Wyrm: a carried draught is drunk instead
of the devouring. A save is NOT a win, so NOTHING resets — level, gold, and
``deepest_rung`` all stand — and it is NOT the devouring either, so the hero
keeps their place at the dungeon. The PUBLIC beat is the survival one
(``wyrm_flee``, "driven back, alive but unproven"), NEVER "devoured". The
turn is still spent and the draught is consumed.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = _doomed_wyrm_challenger(game, "Brak")
potion = game.world.item_by_id("greater_potion")
assert potion is not None
game._satchel_set(player, ["greater_potion"])
floor = len(game.world.settings.dungeon_tiers)
spawn = game.world.spawn
before_turns = player.turns_left
before_level, before_gold = player.level, player.gold
events_before = len(game.events)
out = game.action("Brak", "challenge", "", "")
# Survived standing: hp at the potion's value, no bounce, draught spent.
assert player.hp == min(player.max_hp, potion.heal)
assert (player.x, player.y) != spawn # NOT bounced to the spawn
assert player.mode is Mode.MENU # still standing at the dungeon
assert game._satchel_list(player) == [] # the draught was spent
assert "death's edge" in out.lower() # the spliced survival line
assert player.turns_left == before_turns - 1 # the challenge still cost a turn
# No win, so NO legacy reset: level, gold, and depth all stand.
assert player.wins == 0
assert player.level == before_level
assert player.gold == before_gold
assert player.deepest_rung == floor # depth untouched (no reset to 0)
# The PUBLIC beat is the survival one, NOT the devouring.
assert len(game.events) == events_before + 1
beat = game.events[-1]
assert beat.kind == "wyrm_flee"
assert beat.kind != "wyrm_lose"
assert "fled" in beat.text.lower() or "ran" in beat.text.lower()
def test_challenge_loss_potion_negative_without_save_devours(
tmp_path: Path, clock: object, monkeypatch: pytest.MonkeyPatch
) -> None:
"""NEGATIVE TEST: with the death-save disabled, the same potion-carrier is devoured.
The mechanical equivalent of reverting the added ``_death_save`` call in
``_wyrm_lost``: we stub ``_death_save`` to always decline, then run the exact
scenario of the survival test. The potion-carrier must now bounce to the
spawn at 1 HP with the draught UNSPENT and the PUBLIC beat back to
``wyrm_lose`` (devoured) — proving the death-save (not some other path) is
what saves them at the Wyrm. Restoring the real method (automatic when the
patch lifts) restores the survival behaviour.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = _doomed_wyrm_challenger(game, "Brak")
game._satchel_set(player, ["greater_potion"])
floor = len(game.world.settings.dungeon_tiers)
spawn = game.world.spawn
monkeypatch.setattr(Game, "_death_save", lambda self, pl, lines: False)
out = game.action("Brak", "challenge", "", "")
assert player.hp == 1 # devoured, not saved
assert (player.x, player.y) == spawn
assert player.mode is Mode.TILE
assert player.deepest_rung == floor # a defeat keeps depth (no reset, no advance)
assert game._satchel_list(player) == ["greater_potion"] # the draught is UNSPENT
assert "death's edge" not in out.lower() # no save, no dramatic line
assert game.events[-1].kind == "wyrm_lose" # the devouring beat, not the survival one
def test_challenge_stalemate_counts_as_flight(tmp_path: Path, clock: object) -> None:
"""A 50-round stalemate resolves as a flight: a wyrm_flee news beat.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.6.0"
__version__ = "0.7.0"
+35 -5
View File
@@ -271,7 +271,8 @@ The cross-references the loader enforces:
`locations.json`, and must sit on walkable terrain;
* `settings.starting_weapon` / `starting_armor` must be ids from
`items.json`; `settings.boss_monster` must be an id from `monsters.json`
that is flagged `"boss": true`;
that is flagged `"boss": true`; `settings.rare_drop_item` must be an id from
`items.json` whose `slot` is `consumable`;
* every tier in `settings.dungeon_tiers` must be backed by a NON-boss monster;
* every zone's tier band must overlap at least one monster tier.
@@ -313,6 +314,19 @@ The boss adds an `id` and `"boss": true`, and is referenced by
"xp": 400, "gold": 250, "boss": true, "id": "wyrm_below"}
```
Two optional fields tune random forest encounters. `weight` (default `10`,
must be `> 0`) biases the weighted draw within a zone band — a low weight
surfaces seldom — and `rare` (default `false`) marks a named beast that, on
its kill, fires a public Herald flash and drops the pack's `rare_drop_item`
into the slayer's satchel. Rung guardians ignore both (a rung always takes the
FIRST monster of its tier, never a weighted roll), so a rare should not be the
first entry of a tier that backs a `dungeon_tiers` rung.
```json
{"tier": 2, "name": "the Gilded Stag", "hp": 16, "atk": 6, "def": 2,
"xp": 40, "gold": 60, "weight": 1, "rare": true}
```
### `items.json`
A list of equipment and consumables. `slot` is `weapon`, `armor`, or
@@ -335,9 +349,10 @@ An object keyed by location key. Each entry is a building kind with a menu of
}
```
The verbs the engine understands are `rest` (inn), `buy`/`sell` (shop), `heal`
(healer), `descend`/`challenge` (dungeon), and `leave`. Give each building the
menu that matches its role.
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.
### `events.json`
@@ -434,9 +449,24 @@ boss tier must NOT appear in `settings.dungeon_tiers`: the gauntlet excludes
boss monsters, so a boss-only rung would be unfillable — back every dungeon
tier with at least one ordinary monster.
**The deep, the satchel, and the forge.** `dungeon_tiers` is now a RUNG LADDER
fought one rung per `descend` — list the tiers shallow-to-deep, and make it long
enough to feel like a journey (the Vale uses three). The Wyrm gates on reaching
the floor as well as on level. Size the satchel with `satchel_max` (the Vale
carries 3) — it is the death-save reserve, so keep it small. The forge is the
late-game gold sink: `forge_base_cost` is the price of a +1 edge and scales up
each tier (`base * (current_plus + 1)`), capped at `forge_max_plus`; price it so
a fully-forged piece is a multi-day saving.
**Rare beasts.** A rare monster is a small legend: give it a low `weight` so it
surfaces seldom, stats and rewards a clear notch above its tier, and remember it
always drops `rare_drop_item` (a consumable) into the satchel. Keep rares OFF
the first slot of any `dungeon_tiers` tier, or they would become a fixed rung
guardian instead of a rare roll.
**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.
to the building's role. The shop verbs are `buy`, `sell`, and `forge`.
---
@@ -61,6 +61,14 @@ class Player:
post_day: int = 0
gambles: int = 0
gamble_day: int = 0
# v0.7 "depth below" retention columns: how far the dungeon has been
# plumbed (0 = never descended; N = cleared rung N, 1-indexed), the
# comma-joined carried-potion satchel ('' = empty), and the enhancement
# plus on whichever weapon/armour is CURRENTLY equipped in each slot.
deepest_rung: int = 0
satchel: str = ""
weapon_plus: int = 0
armor_plus: int = 0
@dataclass(frozen=True, slots=True)
@@ -81,6 +89,12 @@ class Monster:
gold: int
monster_id: str = ""
boss: bool = False
# v0.7 weighted forest encounters: ``weight`` biases the random pick (a
# low weight surfaces seldom), ``rare`` marks a named beast that fires a
# public Herald flash and drops a guaranteed draught on the kill. Rung
# guardians ignore both (a rung is a fixed foe, never a weighted roll).
weight: int = 10
rare: bool = False
@dataclass(frozen=True, slots=True)
@@ -183,3 +197,10 @@ class Settings:
post_daily_cap: int
gamble_max_bet: int
gamble_daily_cap: int
# v0.7 "depth below": the carried-potion satchel size, the forge cost
# ladder (base * (current_plus + 1)) and its enhancement ceiling, and the
# consumable item a rare beast is guaranteed to drop on its kill.
satchel_max: int
forge_base_cost: int
forge_max_plus: int
rare_drop_item: str
+407 -45
View File
@@ -114,6 +114,12 @@ _HERALD_TEMPLATES: dict[str, tuple[str, ...]] = {
"{name} took the house for {amount} gold at dice!",
"The dice ran hot for {name}{amount} gold off the house!",
),
# A rare named beast has fallen — loud enough for the whole Vale to hear.
"rare_kill": (
"A rare {monster} has fallen to {name}!",
"{name} has slain the rare {monster} — a deed for the songs!",
"Word races the Vale: {name} felled the rare {monster}!",
),
# --- PRIVATE (via _mail): only the named target reads these ---
"ambushed": ("While you slept: {name} ambushed you — {steal} gold stolen.",),
"post": ("{name} left word for {target}: {text}",),
@@ -200,6 +206,46 @@ class Game:
return None
return cleaned
# -- the satchel: a tiny carried-potion bag (v0.7) -------------------
@staticmethod
def _satchel_list(player: Player) -> list[str]:
"""Return the carried potion ids as a list ('' stored = empty bag)."""
return [item_id for item_id in player.satchel.split(",") if item_id]
@staticmethod
def _satchel_set(player: Player, ids: list[str]) -> None:
"""Store *ids* back onto the player as the comma-joined satchel."""
player.satchel = ",".join(ids)
def _strongest_potion(self, ids: list[str]) -> tuple[int, Item] | None:
"""Return the (index, item) of the highest-heal potion in *ids*, or None.
Resolves each carried id against the pack and picks the one with the
greatest ``heal``; ties keep the earliest. A satchel holding only
unknown ids (a pack edited out from under a save) yields ``None``.
"""
best: tuple[int, Item] | None = None
for index, item_id in enumerate(ids):
item = self.world.item_by_id(item_id)
if item is None:
continue
if best is None or item.heal > best[1].heal:
best = (index, item)
return best
def _satchel_blurb(self, player: Player) -> str:
"""Render the satchel as a status line: contents or an empty note."""
ids = self._satchel_list(player)
if not ids:
return "Satchel: empty"
names = []
for item_id in ids:
item = self.world.item_by_id(item_id)
names.append(item.name if item is not None else item_id)
cap = self.world.settings.satchel_max
return f"Satchel ({len(ids)}/{cap}): " + ", ".join(names)
def _footer(self, player: Player) -> str:
nxt = leveling.xp_for_level(player.level + 1, self.world.settings)
return (
@@ -450,13 +496,18 @@ class Game:
return self._unknown(name)
weapon = self.world.item_by_id(player.weapon_id)
armor = self.world.item_by_id(player.armor_id)
weapon_name = weapon.name if weapon else player.weapon_id
armor_name = armor.name if armor else player.armor_id
rungs = len(self.world.settings.dungeon_tiers)
lines = [
f"Adventurer: {player.name}",
f"Level {player.level} XP {player.xp}",
f"HP {player.hp}/{player.max_hp} ATK {player.atk} DEF {player.def_}",
f"Weapon: {weapon.name if weapon else player.weapon_id}",
f"Armor: {armor.name if armor else player.armor_id}",
f"Weapon: {_with_plus(weapon_name, player.weapon_plus)}",
f"Armor: {_with_plus(armor_name, player.armor_plus)}",
f"Gold {player.gold} Turns {player.turns_left}/{self.world.settings.daily_turns}",
f"Deep: rung {player.deepest_rung}/{rungs}",
self._satchel_blurb(player),
]
return "\n".join(lines) + "\n" + self._footer(player)
@@ -558,9 +609,11 @@ class Game:
if verb == "post":
return self._post(player, target, text)
if verb == "quaff":
return self._quaff(player)
if player.mode is Mode.TILE:
return self._tile_action(player, verb, target)
return self._menu_action(player, verb, item, amount)
return self._menu_action(player, verb, target, item, amount)
# -- tile-context actions (fight / flee / ambush) --------------------
@@ -575,13 +628,22 @@ class Game:
)
def _pick_monster(self, player: Player) -> Monster | None:
"""Pick a random forest foe from the player's zone band, WEIGHTED.
Each non-boss monster in the band is drawn in proportion to its
``weight`` (default 10), so a low-weight rare (weight 1) surfaces
seldom among the common foes. Rung guardians do NOT come through here —
``_descend`` takes ``band[0]`` directly, so a rung is a fixed,
repeatable guardian rather than a weighted roll.
"""
zone = self.world.zone_for(player.x, player.y)
if zone is None:
return None
band = self.world.monsters_for_tier_band(zone.tier_lo, zone.tier_hi)
if not band:
return None
return band[self.rng.choice_index(len(band))]
weights = [m.weight for m in band]
return band[self.rng.weighted_index(weights)]
def _resolve_encounter(self, player: Player, verb: str) -> str:
self._ensure_day(player)
@@ -599,7 +661,7 @@ class Game:
result = combat.resolve_flee(child, player, monster)
else:
result = combat.resolve_fight(child, player, monster)
return self._apply_fight(player, result)
return self._apply_fight(player, result, monster)
def _apply_xp_with_herald(
self, player: Player, amount: int, lines: list[str]
@@ -635,7 +697,70 @@ class Game:
f"The {result.monster_name} falls. +{result.xp_delta} XP, +{result.gold_delta} gold."
)
def _apply_fight(self, player: Player, result: combat.FightResult) -> str:
_DEATH_SAVE_LINE = (
"As the dark closes in, the elixir burns down your throat — "
"you stagger back from death's edge."
)
def _death_save(self, player: Player, lines: list[str]) -> bool:
"""Spend the strongest carried potion to cheat a lethal loss, if able.
The v0.7 death-save: called on the ACTIVE fighter the instant a fight
is about to bounce them to the spawn. If they carry at least one
usable potion, the strongest is drunk instead of the bounce — hp is set
to that potion's heal (capped at max_hp), the potion leaves the
satchel, a dramatic line is spliced into the narration, and the method
returns ``True`` so the caller skips the spawn reset. With an empty (or
all-unknown) satchel it does nothing and returns ``False``.
This lives entirely in the façade: ``combat`` only reports the outcome,
and the satchel + the survival are decided here.
"""
ids = self._satchel_list(player)
best = self._strongest_potion(ids)
if best is None:
return False
index, potion = best
del ids[index]
self._satchel_set(player, ids)
player.hp = min(player.max_hp, potion.heal)
lines.append(self._DEATH_SAVE_LINE)
return True
def _satchel_try_add(self, player: Player, item_id: str) -> bool:
"""Drop *item_id* into the satchel if there is room; return success.
Returns ``False`` (without mutation) when the bag is already at
``satchel_max``. The single chokepoint for adding to the satchel, so
the cap is enforced in exactly one place.
"""
ids = self._satchel_list(player)
if len(ids) >= self.world.settings.satchel_max:
return False
ids.append(item_id)
self._satchel_set(player, ids)
return True
def _apply_rare_kill(
self, player: Player, monster: Monster, lines: list[str]
) -> list[EventSpec]:
"""Celebrate a rare kill: a public Herald flash and a guaranteed drop.
Splices the drop line into *lines* (into the satchel if there is room,
else a "no room" note) and returns the public ``rare_kill`` beat for
the feed. The drop item is the pack's validated ``rare_drop_item`` (a
known consumable), so it is always safe to add by id.
"""
drop_id = self.world.settings.rare_drop_item
if self._satchel_try_add(player, drop_id):
lines.append("It guarded a draught — into your satchel it goes.")
else:
lines.append("It guarded a draught — but your satchel had no room.")
return [self._herald("rare_kill", player.name, monster=monster.name)]
def _apply_fight(
self, player: Player, result: combat.FightResult, monster: Monster | None = None
) -> str:
lines = list(result.log)
events: list[EventSpec] = []
@@ -644,8 +769,10 @@ class Game:
self._append_kill_and_reward(lines, result)
player.gold += result.gold_delta
events.extend(self._apply_xp_with_herald(player, result.xp_delta, lines))
if monster is not None and monster.rare:
events.extend(self._apply_rare_kill(player, monster, lines))
if result.bounce_to_spawn:
if result.bounce_to_spawn and not self._death_save(player, lines):
player.hp = 1
player.x, player.y = self.world.spawn
events.append(self._herald("defeat", player.name, monster=result.monster_name))
@@ -752,9 +879,14 @@ class Game:
events.append(self._herald("ambush", attacker.name, target=target.name, steal=steal))
events.append(self._mail("ambushed", attacker.name, target.name, steal=steal))
elif result.bounce_to_spawn:
attacker.hp = 1
attacker.x, attacker.y = self.world.spawn
# The ATTACKER is the active fighter, so their satchel can save them
# from a lethal counter-strike (the sleeping VICTIM never quaffs —
# they are asleep). A death-save keeps the attacker standing where
# they are; otherwise they bounce to the spawn at 1 HP.
lines.append(f"{target.name} wakes blade-in-hand and you flee bleeding.")
if not self._death_save(attacker, lines):
attacker.hp = 1
attacker.x, attacker.y = self.world.spawn
events.append(self._herald("ambush_shame", attacker.name, target=target.name))
else:
# A grinding stalemate: no gold moves, but the attacker keeps any
@@ -770,7 +902,7 @@ class Game:
# -- menu-context actions --------------------------------------------
def _menu_action(self, player: Player, verb: str, item: str, amount: int) -> str:
def _menu_action(self, player: Player, verb: str, target: str, item: str, amount: int) -> str:
loc = self.world.location_by_key(player.at_location)
if loc is None:
player.mode = Mode.TILE
@@ -792,6 +924,8 @@ class Game:
return self._buy(player, item)
if verb == "sell":
return self._sell(player)
if verb == "forge":
return self._forge(player, target)
if verb == "descend":
return self._descend(player)
if verb == "challenge":
@@ -848,26 +982,39 @@ class Game:
player,
lines=[f"The {item.name} costs {item.price}; you hold {player.gold}."],
)
# A consumable goes into the satchel to carry; a full bag refuses the
# sale (no gold spent). Weapons/armour equip immediately as before.
if item.slot is Slot.CONSUMABLE and not self._satchel_try_add(player, item.item_id):
return self._location_menu(
player,
lines=["Your satchel bulges — no room for another draught."],
)
player.gold -= item.price
line = self._equip_or_quaff(player, item)
line = self._equip_or_stow(player, item)
# Shopping is a private errand; persist without Herald news.
self._persist(player)
return self._location_menu(player, lines=[line])
def _equip_or_quaff(self, player: Player, item: Item) -> str:
def _equip_or_stow(self, player: Player, item: Item) -> str:
"""Equip a weapon/armour (clearing its slot first) or stow a draught.
Buying gear clears the old slot through the shared unequip helper — which
drops the old base bonus AND the forged plus and zeroes the plus, so a +N
blade replaced leaves no phantom stat — then adds the new base bonus.
Consumables were already placed in the satchel by the caller; this only
narrates the stow.
"""
if item.slot is Slot.WEAPON:
player.atk += item.atk - self._equipped_bonus(player.weapon_id, Slot.WEAPON)
self._clear_weapon_slot(player)
player.atk += item.atk
player.weapon_id = item.item_id
return f"You take up the {item.name}. (-{item.price} gold)"
if item.slot is Slot.ARMOR:
player.def_ += item.def_ - self._equipped_bonus(player.armor_id, Slot.ARMOR)
self._clear_armor_slot(player)
player.def_ += item.def_
player.armor_id = item.item_id
return f"You don the {item.name}. (-{item.price} gold)"
before = player.hp
player.hp = min(player.max_hp, player.hp + item.heal)
return (
f"You quaff the {item.name}, recovering {player.hp - before} HP. (-{item.price} gold)"
)
return f"You stow the {item.name} in your satchel. (-{item.price} gold)"
def _equipped_bonus(self, item_id: str, slot: Slot) -> int:
item = self.world.item_by_id(item_id)
@@ -875,6 +1022,29 @@ class Game:
return 0
return item.atk if slot is Slot.WEAPON else item.def_
def _clear_weapon_slot(self, player: Player) -> None:
"""Drop the equipped weapon's base bonus AND forged plus, then zero it.
The single home for the weapon-slot unequip invariant: a swap, a sell,
and the legacy reset all clear the slot through here, so a forged +N can
never be left as phantom atk on the next blade. Subtracts the current
weapon's base ATK bonus and ``weapon_plus`` from the live stat and zeroes
the plus; the caller then equips whatever comes next.
"""
player.atk -= self._equipped_bonus(player.weapon_id, Slot.WEAPON) + player.weapon_plus
player.weapon_plus = 0
def _clear_armor_slot(self, player: Player) -> None:
"""Drop the equipped armour's base bonus AND forged plus, then zero it.
The armour twin of :meth:`_clear_weapon_slot` — the one home for the
armour-slot unequip invariant, so a forged +N never lingers as phantom
DEF. Subtracts the current armour's base DEF bonus and ``armor_plus``
from the live stat and zeroes the plus.
"""
player.def_ -= self._equipped_bonus(player.armor_id, Slot.ARMOR) + player.armor_plus
player.armor_plus = 0
def _sell(self, player: Player) -> str:
weapon = self.world.item_by_id(player.weapon_id)
# The starter blade is never sellable, whatever a pack prices it at —
@@ -889,7 +1059,11 @@ class Game:
player.gold += refund
starter = self.world.settings.starting_weapon
fallback = self.world.item_by_id(starter)
player.atk -= weapon.atk - (fallback.atk if fallback else 0)
# Clear the sold slot through the shared helper (drops the blade's base
# bonus AND any forged plus, zeroes the plus), then fall back to the
# starter and add its base bonus.
self._clear_weapon_slot(player)
player.atk += fallback.atk if fallback else 0
player.weapon_id = starter
# Selling back gear is a private errand; persist without Herald news.
self._persist(player)
@@ -907,7 +1081,8 @@ class Game:
continue
stat = self._stat_blurb(item)
lines.append(f" {item.item_id:<14} {item.price:>4}g {item.name} {stat}")
lines.append("Buying weapon/armour equips it; potions are drunk at once.")
lines.append("Buying weapon/armour equips it; potions go into your satchel.")
lines.append("Forge a +1 edge with (F)orge weapon / armour; (Q)uaff a draught anywhere.")
return lines
@staticmethod
@@ -963,6 +1138,98 @@ class Game:
self._persist(player, note)
return self._surface(player, lines=["The innkeep tucks the note above the hearth."])
# -- quaff: drink the strongest carried potion (anywhere; no turn) ---
def _quaff(self, player: Player) -> str:
"""Drink the strongest carried potion (legal anywhere, costs no turn).
Picks the highest-heal draught in the satchel, heals up to ``max_hp``,
and removes it. An empty satchel refuses; quaffing at full HP refuses
too, so a draught is never wasted. Renders on the player's current
surface (map or menu), like ``post``.
"""
ids = self._satchel_list(player)
best = self._strongest_potion(ids)
if best is None:
return self._surface(player, lines=["Your satchel is empty."])
if player.hp >= player.max_hp:
return self._surface(
player,
lines=["You are already hale; the draught stays corked."],
)
index, potion = best
before = player.hp
player.hp = min(player.max_hp, player.hp + potion.heal)
del ids[index]
self._satchel_set(player, ids)
self._persist(player)
return self._surface(
player,
lines=[f"You quaff the {potion.name}, recovering {player.hp - before} HP."],
)
# -- forge: spend gold to enhance the equipped weapon or armour ------
def _forge(self, player: Player, target: str) -> str:
"""Enhance the equipped weapon or armour by +1, the late-game gold sink.
``target`` is "weapon" or "armour"/"armor". Cost is
``forge_base_cost * (current_plus + 1)``, so each tier costs more; the
plus is capped at ``forge_max_plus``. On success the gold is deducted,
the slot's plus rises, and the LIVE stat rises with it (weapon→atk,
armour→def). An unaffordable or capped forge refuses without mutation;
a missing/invalid target asks which slot.
"""
slot = target.strip().lower()
if slot == "weapon":
return self._forge_slot(
player, current=player.weapon_plus, label="weapon", apply=self._forge_weapon
)
if slot in {"armor", "armour"}:
return self._forge_slot(
player, current=player.armor_plus, label="armour", apply=self._forge_armor
)
return self._location_menu(player, lines=["Forge what — (weapon) or (armour)?"])
def _forge_slot(
self,
player: Player,
*,
current: int,
label: str,
apply: Callable[[Player], None],
) -> str:
"""Shared forge accounting for one slot: cap, cost, deduct, apply."""
settings = self.world.settings
if current >= settings.forge_max_plus:
return self._location_menu(player, lines=[f"Your {label} can take no finer edge."])
cost = settings.forge_base_cost * (current + 1)
if player.gold < cost:
return self._location_menu(
player,
lines=[
f"The smith wants {cost} gold to better your {label}; you hold {player.gold}."
],
)
player.gold -= cost
apply(player)
self._persist(player)
new_plus = player.weapon_plus if label == "weapon" else player.armor_plus
return self._location_menu(
player,
lines=[f"The smith works your {label} to +{new_plus}. (-{cost} gold)"],
)
@staticmethod
def _forge_weapon(player: Player) -> None:
player.weapon_plus += 1
player.atk += 1
@staticmethod
def _forge_armor(player: Player) -> None:
player.armor_plus += 1
player.def_ += 1
# -- the inn dice game: wager against the house ---------------------
def _gamble(self, player: Player, amount: int) -> str:
@@ -1012,27 +1279,63 @@ class Game:
return self._location_menu(player, lines=[line])
def _descend(self, player: Player) -> str:
"""Run the dungeon gauntlet: one foe per configured tier, back to back."""
"""Descend ONE rung of the deep — the next guardian past your deepest.
The deep is a ladder of ``settings.dungeon_tiers`` rungs, fought one per
descent: a descent faces ``dungeon_tiers[deepest_rung]`` (the rung after
your deepest), the fixed guardian ``band[0]`` of that tier. A win
advances ``deepest_rung``; reaching the last rung opens the Wyrm's door.
A loss bounces you to the spawn but PRESERVES ``deepest_rung`` — you
re-enter where you left off. Standing already at the bottom, descend
costs no turn and simply points you at the challenge.
"""
tiers = self.world.settings.dungeon_tiers
floor = len(tiers)
if player.deepest_rung >= floor:
return self._location_menu(
player,
lines=[
"You have plumbed the deep to its floor. There is nothing below "
"now but the Wyrm itself — challenge it when you are ready."
],
)
self._ensure_day(player)
if not turns.spend_turn(player):
return self._location_menu(
player,
lines=["You're too weary to descend today. Return tomorrow."],
)
lines = ["You descend into the Understone Deep..."]
# band[0] on purpose: the rung guardian is a fixed, repeatable foe (the
# classic fixed-foe convention), never a weighted roll.
next_rung = player.deepest_rung # 0-indexed into the tier ladder
tier = tiers[next_rung]
band = self.world.monsters_for_tier_band(tier, tier)
# The loader guarantees every dungeon tier is backed by a non-boss
# monster, so band is non-empty; guard defensively all the same.
if not band:
return self._location_menu(
player, lines=["The way down is choked with rubble; no foe stirs here."]
)
monster = band[0]
lines = [f"You descend to the {_ordinal(next_rung + 1)} rung of the Understone Deep..."]
events: list[EventSpec] = []
for tier in self.world.settings.dungeon_tiers:
band = self.world.monsters_for_tier_band(tier, tier)
if not band:
continue
# band[0] on purpose: the gauntlet ladder is a fixed, repeatable
# encounter (the classic fixed-foe convention), never randomized.
monster = band[0]
result = combat.resolve_fight(self.rng.child(), player, monster)
lines.append("")
lines.extend(result.log)
player.hp = max(1, player.hp + result.hp_delta)
if result.bounce_to_spawn:
result = combat.resolve_fight(self.rng.child(), player, monster)
lines.append("")
lines.extend(result.log)
player.hp = max(1, player.hp + result.hp_delta)
if result.bounce_to_spawn:
# A carried draught saves the active fighter in ANY fight, the deep
# included: the strongest potion is drunk instead of the bounce. A
# save keeps the hero standing at the dungeon — depth UNCHANGED (the
# rung was not cleared, but the depth already earned is kept), the
# turn still spent. Without a potion it is the standard spawn bounce.
# A descent loss is private, so the public defeat herald is written
# only on a genuine (un-saved) bounce.
if not self._death_save(player, lines):
player.hp = 1
player.x, player.y = self.world.spawn
player.mode = Mode.TILE
@@ -1040,12 +1343,30 @@ class Game:
events.append(self._herald("defeat", player.name, monster=monster.name))
self._persist(player, *events)
return self._overworld_frame(player, lines=lines)
if result.outcome is combat.Outcome.WIN:
self._append_kill_and_reward(lines, result)
player.gold += result.gold_delta
events.extend(self._apply_xp_with_herald(player, result.xp_delta, lines))
lines.append("")
lines.append("You climb back to the surface, victorious and laden.")
self._persist(player, *events)
return self._location_menu(player, lines=lines)
if result.outcome is combat.Outcome.WIN:
self._append_kill_and_reward(lines, result)
player.gold += result.gold_delta
events.extend(self._apply_xp_with_herald(player, result.xp_delta, lines))
player.deepest_rung = next_rung + 1
lines.append("")
if player.deepest_rung >= floor:
lines.append(
"You stand at the Wyrm's door. The deep has no deeper — only the "
"Wyrm Below remains. Challenge it when you are ready."
)
else:
lines.append(
f"You have reached rung {player.deepest_rung} of {floor}, and climb "
"back to the surface to gather your strength."
)
else:
# A stalemate flight: no rung gained, but the wear taken is kept.
lines.append("")
lines.append("You break off and climb back to the surface, winded.")
self._persist(player, *events)
return self._location_menu(player, lines=lines)
@@ -1068,6 +1389,17 @@ class Game:
"circle or beyond. Grow stronger, then return."
],
)
# The depth gate, checked AFTER the level gate so a low-level shallow
# hero hears the level message first: the Wyrm will not stir until the
# deep has been plumbed rung by rung to its floor.
if player.deepest_rung < len(self.world.settings.dungeon_tiers):
return self._location_menu(
player,
lines=[
"The Wyrm Below will not stir for one who has not yet plumbed "
"the deep to its floor."
],
)
boss = self.world.monster_by_id(self.world.settings.boss_monster)
if boss is None:
@@ -1119,8 +1451,20 @@ class Game:
return self._overworld_frame(player, lines=lines)
def _wyrm_lost(self, player: Player, result: combat.FightResult) -> str:
"""Standard defeat: bounce to spawn at 1 HP, herald the devouring."""
"""A lethal Wyrm bout: a carried draught saves the hero, else they fall.
The universal death-save reaches the Wyrm too: a potion in the satchel
is drunk instead of the devouring. A save is NOT a win — there is no
legacy reset (level/gold/depth all stand) — but it is NOT the devouring
either, so the hero keeps their place at the dungeon and the PUBLIC beat
is the survival one (the ``wyrm_flee`` "driven back, alive but unproven"
herald), never the "devoured" lie. The turn is spent regardless. With no
potion it is the standard defeat: bounce to the spawn at 1 HP, devoured.
"""
lines = list(result.log)
if self._death_save(player, lines):
self._persist(player, self._herald("wyrm_flee", player.name))
return self._location_menu(player, lines=lines)
player.hp = 1
player.x, player.y = self.world.spawn
player.mode = Mode.TILE
@@ -1148,11 +1492,19 @@ class Game:
Stats, equipment, HP, gold, level/xp and position all return to the
first-day baseline and ``created_at`` is restamped; ``wins`` rises by
one. The daily clock (turns/turn_day), the bestow pool, and the log
cursor are deliberately UNTOUCHED — a legacy run is a fresh character,
not a fresh day.
one. The v0.7 retention state — dungeon depth, forged enhancements, and
the satchel — resets too (a reborn hero re-earns the deep and carries
nothing forged or bottled). The daily clock (turns/turn_day), the
bestow pool, and the log cursor are deliberately UNTOUCHED — a legacy
run is a fresh character, not a fresh day.
"""
settings = self.world.settings
# Clear both gear slots through the shared unequip helpers FIRST, so the
# forged-plus reset lives in the one place every slot change uses (the
# fresh stats below are absolute, but the plus-zeroing invariant stays
# centralised rather than hand-copied here).
self._clear_weapon_slot(player)
self._clear_armor_slot(player)
atk, def_, max_hp = self._fresh_combat_stats()
player.wins += 1
player.level = 1
@@ -1168,6 +1520,11 @@ class Game:
player.mode = Mode.TILE
player.at_location = ""
player.created_at = self._now_iso()
# The deep and the satchel are first-day fresh too: a reborn hero must
# plumb the deep again and carries no draught across the renewal (the
# forged edges were already cleared with the gear slots above).
player.deepest_rung = 0
player.satchel = ""
# -- tool: log -------------------------------------------------------
@@ -1389,3 +1746,8 @@ def _ordinal(n: int) -> str:
if 0 <= n < len(_ORDINALS):
return _ORDINALS[n]
return f"{n}th"
def _with_plus(name: str, plus: int) -> str:
"""Append a ``+N`` enhancement suffix to an item name (blank at zero)."""
return f"{name} +{plus}" if plus > 0 else name
+17 -1
View File
@@ -65,6 +65,10 @@ _PLAYER_COLUMNS = (
"post_day",
"gambles",
"gamble_day",
"deepest_rung",
"satchel",
"weapon_plus",
"armor_plus",
)
@@ -109,7 +113,11 @@ class Store:
posts_sent INTEGER NOT NULL DEFAULT 0,
post_day INTEGER NOT NULL DEFAULT 0,
gambles INTEGER NOT NULL DEFAULT 0,
gamble_day INTEGER NOT NULL DEFAULT 0
gamble_day INTEGER NOT NULL DEFAULT 0,
deepest_rung INTEGER NOT NULL DEFAULT 0,
satchel TEXT NOT NULL DEFAULT '',
weapon_plus INTEGER NOT NULL DEFAULT 0,
armor_plus INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (
@@ -326,6 +334,10 @@ def _player_to_row(player: Player) -> tuple[object, ...]:
player.post_day,
player.gambles,
player.gamble_day,
player.deepest_rung,
player.satchel,
player.weapon_plus,
player.armor_plus,
)
@@ -357,6 +369,10 @@ def _row_to_player(row: sqlite3.Row) -> Player:
post_day=row["post_day"],
gambles=row["gambles"],
gamble_day=row["gamble_day"],
deepest_rung=row["deepest_rung"],
satchel=row["satchel"],
weapon_plus=row["weapon_plus"],
armor_plus=row["armor_plus"],
)
+53 -18
View File
@@ -113,12 +113,39 @@ WANDERING THE FOREST (the texture of a walk)
them as the quiet texture of travelling, and watch the lore: it whispers of
something coiled beneath the dungeon.
DELVING DEEP (the reasons to come back)
Beneath the daily reset are four standing draws that reward a returning hero.
* THE RUNG LADDER. The dungeon is a ladder of guardians fought one rung per
'descend' (each costs a daily turn). A descent faces the NEXT rung past your
deepest; a win advances your depth and you climb back out, a loss bounces
you home but your depth PERSISTS — you re-enter where you left off. Reaching
the last rung opens the Wyrm's door (the depth gate above). Narrate the deep
as a slow, earned descent, a rung at a time.
* THE SATCHEL AND THE DEATH-SAVE. A small satchel carries a few potions
(buying one at the shop now STOWS it instead of drinking it). 'quaff'
(anywhere, no turn) drinks the strongest. The heart of it: if a fight would
KILL the active fighter and they carry a potion, the strongest is drunk
AUTOMATICALLY — they survive standing at the potion's value, no bounce, no
spawn reset. Play that beat big: the elixir burning down their throat at the
edge of death. (A sleeping ambush victim never auto-quaffs — they are
asleep.)
* THE FORGE. The shop's forge spends gold to add a +1 edge to the equipped
weapon or armour ('forge' target="weapon"/"armour"), up to a cap, each tier
dearer than the last — the late-game gold sink. Swapping or selling that
piece loses the edge with it.
* RARE BEASTS. A few named beasts prowl the forest, surfacing seldom. Felling
one is loud — a public Herald flash — and it always guards a draught that
drops into the satchel (if there is room). Treat a rare kill as a small
legend in itself.
THE WYRM BELOW (the endgame, and how to win)
Deep under the dungeon sleeps the Wyrm Below — a fixed, fearsome boss and
the ONLY win condition. At the dungeon, a sufficiently seasoned hero may
'challenge' it (door_action action="challenge"). Under the level threshold,
the server turns them away in-fiction; once allowed, the challenge spends a
daily turn and resolves in one call, like a fight.
'challenge' it (door_action action="challenge"). The Wyrm gates on BOTH
level AND depth: an under-level hero is turned away first, and even a high
hero who has not plumbed the deep to its floor (see DELVING DEEP) is told the
Wyrm will not stir. Once both are met, the challenge spends a daily turn and
resolves in one call, like a fight.
* On victory the hero FREES THE VALE. The triumph is heralded to everyone,
the run is carved into the Hall of Legends (shown by door_rank), and the
hero is reborn in a classic-door-game-style legacy reset: level, gold, gear and stats
@@ -170,9 +197,10 @@ TOOL CHEAT-SHEET
door_move(player, ...) Walk the overworld (free). steps="NNEE" or
heading="east" + distance=3 (max 8 per call).
door_action(player, action) Context verb: fight, flee, ambush (a rival),
rest, buy, sell, heal, gamble (dice at the
inn), descend, challenge (the Wyrm), post (a
note for another player), leave.
rest, buy, sell, forge (a +1 edge), heal,
gamble (dice at the inn), descend (one rung),
challenge (the Wyrm), post (a note), quaff (a
carried potion), leave.
door_log(player) Read the Understone Herald (the shared feed).
door_rank(player) The leaderboard + Hall of Legends (★ = wins).
door_bestow(player, reason...) Grant a little gold/healing for a story beat.
@@ -344,22 +372,29 @@ def door_action(
'fight' or 'flee' a wandering monster (fighting spends one daily turn), or
'ambush' a named rival who has not yet acted today — a sleeping-rival
robbery in the spirit of the classic door-game player-kill (target=<name>, spends a turn).
Inside a building: 'rest' (inn), 'buy'/'sell' (shop), 'heal' (healer), or
'leave'. At the inn you may also 'gamble' a stake of gold at dice
(amount=<gold>). At the dungeon: 'descend' the gauntlet, or 'challenge' the
Wyrm Below — the endgame boss and the only way to win. The challenge is
gated by level (under-level heroes are turned away in-fiction) and, once
allowed, spends a daily turn and resolves in a single call like a fight:
a victory frees the Vale and begins a new life (see door_help), a defeat
bounces you home. Anywhere, 'post' leaves a private note for another player
(target=<name>, text=<message>) that they read on their next door_log;
posting costs no turn. An illegal verb returns the verbs valid right here.
Inside a building: 'rest' (inn), 'buy'/'sell'/'forge' (shop), 'heal'
(healer), or 'leave'. At the inn you may also 'gamble' a stake of gold at
dice (amount=<gold>). Buying a potion now stows it in your satchel rather
than drinking it; 'forge' (target="weapon"/"armour", at the shop) spends
gold to add a +1 edge to your equipped gear, up to a cap. At the dungeon:
'descend' ONE rung of the deep — the next guardian past your deepest, one
per turn — or 'challenge' the Wyrm Below, the endgame boss and the only way
to win. Descending a rung advances your depth; reaching the floor opens the
Wyrm's door. The challenge is gated by BOTH level and depth (you must have
plumbed the deep to its floor) and, once allowed, spends a daily turn and
resolves in a single call like a fight: a victory frees the Vale and begins
a new life (see door_help), a defeat bounces you home. Anywhere and at no
turn cost: 'post' leaves a private note for another player (target=<name>,
text=<message>) read on their next door_log, and 'quaff' drinks the
strongest potion from your satchel. An illegal verb returns the verbs valid
right here.
Args:
player: The adventurer's name.
action: The verb to attempt (fight, flee, ambush, rest, buy, sell,
heal, gamble, descend, challenge, post, leave).
target: The other player's name for 'ambush' and 'post'.
forge, heal, gamble, descend, challenge, post, quaff, leave).
target: The other player's name for 'ambush'/'post', or the slot
("weapon"/"armour") for 'forge'.
item: For shop 'buy', the item id to purchase.
text: For 'post', the note left for the target (<= 120 characters).
amount: For inn 'gamble', the gold wagered on the dice.
@@ -17,11 +17,12 @@
"name": "Gravel & Sons Outfitters",
"glyph": "$",
"color": "town",
"actions": ["buy", "sell", "leave"],
"actions": ["buy", "sell", "forge", "leave"],
"flavor": [
"Racks of steel and leather line the walls.",
"Buying a weapon or armour equips it at once.",
"Potions are quaffed on purchase (no pack to carry).",
"Potions go into your satchel, to quaff when the need is dire.",
"The forge glows: bring coin to better your blade or your guard.",
"Old gear sells back for half its price."
]
},
@@ -35,6 +35,17 @@
"xp": 20,
"gold": 9
},
{
"tier": 2,
"name": "the Gilded Stag",
"hp": 16,
"atk": 6,
"def": 2,
"xp": 40,
"gold": 60,
"weight": 1,
"rare": true
},
{
"tier": 3,
"name": "Forest Wolf",
@@ -53,6 +64,17 @@
"xp": 38,
"gold": 16
},
{
"tier": 3,
"name": "the Hollow Knight",
"hp": 30,
"atk": 11,
"def": 4,
"xp": 80,
"gold": 110,
"weight": 1,
"rare": true
},
{
"tier": 4,
"name": "Cave Troll",
@@ -114,7 +114,7 @@
"def": 1
},
"bestow_daily_budget": 25,
"dungeon_tiers": [4, 5],
"dungeon_tiers": [3, 4, 5],
"boss_monster": "wyrm_below",
"wyrm_min_level": 6,
"ambush_min_level": 3,
@@ -122,6 +122,10 @@
"ambush_gold_pct": 25,
"post_daily_cap": 5,
"gamble_max_bet": 50,
"gamble_daily_cap": 5
"gamble_daily_cap": 5,
"satchel_max": 3,
"forge_base_cost": 60,
"forge_max_plus": 3,
"rare_drop_item": "greater_potion"
}
}
@@ -54,6 +54,9 @@ SETTINGS_BANDS: dict[str, tuple[int, int | None]] = {
"post_daily_cap": (0, 50),
"gamble_max_bet": (1, 10000),
"gamble_daily_cap": (0, 100),
"satchel_max": (1, 10),
"forge_base_cost": (1, 10000),
"forge_max_plus": (0, 10),
}
# Per-kind amount bands for the overworld event table (inclusive).
@@ -215,6 +218,9 @@ def _load_monsters(root: Path) -> list[Monster]:
for label, val in (("atk", atk), ("def", def_), ("xp", xp), ("gold", gold)):
if val < 0:
raise WorldLoadError(f"{where} {label} must be >= 0, got {val}")
weight = int(spec.get("weight", 10))
if weight <= 0:
raise WorldLoadError(f"{where} weight must be > 0, got {weight}")
out.append(
Monster(
tier=int(_require(spec, "tier", where)),
@@ -226,6 +232,8 @@ def _load_monsters(root: Path) -> list[Monster]:
gold=gold,
monster_id=str(spec.get("id", "")),
boss=bool(spec.get("boss", False)),
weight=weight,
rare=bool(spec.get("rare", False)),
)
)
if not out:
@@ -594,6 +602,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)
return Settings(
daily_turns=values["daily_turns"],
@@ -619,6 +628,10 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons
post_daily_cap=values["post_daily_cap"],
gamble_max_bet=values["gamble_max_bet"],
gamble_daily_cap=values["gamble_daily_cap"],
satchel_max=values["satchel_max"],
forge_base_cost=values["forge_base_cost"],
forge_max_plus=values["forge_max_plus"],
rare_drop_item=rare_drop_item,
)
@@ -642,6 +655,29 @@ def _decode_boss_monster(spec: dict[str, Any], monsters: list[Monster]) -> str:
return boss_id
def _decode_rare_drop_item(spec: dict[str, Any], items: list[Item]) -> str:
"""Resolve and validate the item a rare beast drops on its kill.
The id must name an item in the pack AND that item must be a consumable
(it goes straight into the satchel to be quaffed later, so a weapon or
armour id would be incoherent). Mirrors the ``starting_weapon`` check but
adds the slot constraint.
"""
drop_id = str(_require(spec, "rare_drop_item", "world.json settings"))
by_id = {it.item_id: it for it in items}
item = by_id.get(drop_id)
if item is None:
raise WorldLoadError(
f"world.json settings.rare_drop_item = {drop_id!r} is not a known item id"
)
if item.slot is not Slot.CONSUMABLE:
raise WorldLoadError(
f"world.json settings.rare_drop_item = {drop_id!r} must be a consumable item, "
f"not {item.slot.value!r}"
)
return drop_id
def _decode_dungeon_tiers(spec: dict[str, Any], monsters: list[Monster]) -> tuple[int, ...]:
"""Parse the ordered dungeon-gauntlet tier ladder, one foe per tier.