diff --git a/examples/door-game/README.md b/examples/door-game/README.md index be16c5be..67f4bb58 100644 --- a/examples/door-game/README.md +++ b/examples/door-game/README.md @@ -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. | diff --git a/examples/door-game/pyproject.toml b/examples/door-game/pyproject.toml index 4dd1b515..b8d1c664 100644 --- a/examples/door-game/pyproject.toml +++ b/examples/door-game/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "understone" -version = "0.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" diff --git a/examples/door-game/tests/conftest.py b/examples/door-game/tests/conftest.py index 709baf7f..4c151a56 100644 --- a/examples/door-game/tests/conftest.py +++ b/examples/door-game/tests/conftest.py @@ -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] diff --git a/examples/door-game/tests/test_descend.py b/examples/door-game/tests/test_descend.py new file mode 100644 index 00000000..5ca937d7 --- /dev/null +++ b/examples/door-game/tests/test_descend.py @@ -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 diff --git a/examples/door-game/tests/test_game.py b/examples/door-game/tests/test_game.py index d3b31813..a76fa53b 100644 --- a/examples/door-game/tests/test_game.py +++ b/examples/door-game/tests/test_game.py @@ -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 # --------------------------------------------------------------------------- diff --git a/examples/door-game/tests/test_package.py b/examples/door-game/tests/test_package.py index 84845998..76949e67 100644 --- a/examples/door-game/tests/test_package.py +++ b/examples/door-game/tests/test_package.py @@ -6,4 +6,4 @@ import understone def test_version_present() -> None: - assert understone.__version__ == "0.6.0" + assert understone.__version__ == "0.7.0" diff --git a/examples/door-game/tests/test_persistence.py b/examples/door-game/tests/test_persistence.py index 800d43ea..529dda22 100644 --- a/examples/door-game/tests/test_persistence.py +++ b/examples/door-game/tests/test_persistence.py @@ -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() diff --git a/examples/door-game/tests/test_world_loader.py b/examples/door-game/tests/test_world_loader.py index 0a63e407..dcef96b5 100644 --- a/examples/door-game/tests/test_world_loader.py +++ b/examples/door-game/tests/test_world_loader.py @@ -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 diff --git a/examples/door-game/tests/test_wyrm.py b/examples/door-game/tests/test_wyrm.py index f92145f4..90bf464d 100644 --- a/examples/door-game/tests/test_wyrm.py +++ b/examples/door-game/tests/test_wyrm.py @@ -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. diff --git a/examples/door-game/understone/__init__.py b/examples/door-game/understone/__init__.py index f2ab94d4..17fabd42 100644 --- a/examples/door-game/understone/__init__.py +++ b/examples/door-game/understone/__init__.py @@ -1,3 +1,3 @@ """Understone — a BBS-style ANSI door game served over MCP.""" -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/examples/door-game/understone/cli.py b/examples/door-game/understone/cli.py index c60067ca..81e61daa 100644 --- a/examples/door-game/understone/cli.py +++ b/examples/door-game/understone/cli.py @@ -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`. --- diff --git a/examples/door-game/understone/engine/models.py b/examples/door-game/understone/engine/models.py index cbe68461..c83d3f7d 100644 --- a/examples/door-game/understone/engine/models.py +++ b/examples/door-game/understone/engine/models.py @@ -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 diff --git a/examples/door-game/understone/game.py b/examples/door-game/understone/game.py index bb3029cf..799f3fe6 100644 --- a/examples/door-game/understone/game.py +++ b/examples/door-game/understone/game.py @@ -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 diff --git a/examples/door-game/understone/persistence.py b/examples/door-game/understone/persistence.py index 434dc369..4da66a52 100644 --- a/examples/door-game/understone/persistence.py +++ b/examples/door-game/understone/persistence.py @@ -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"], ) diff --git a/examples/door-game/understone/server.py b/examples/door-game/understone/server.py index 59fb12b1..83082c0d 100644 --- a/examples/door-game/understone/server.py +++ b/examples/door-game/understone/server.py @@ -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=, 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=). 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=, text=) 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=). 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=, + text=) 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. diff --git a/examples/door-game/understone/world/data/locations.json b/examples/door-game/understone/world/data/locations.json index 4affbbd8..4bf51862 100644 --- a/examples/door-game/understone/world/data/locations.json +++ b/examples/door-game/understone/world/data/locations.json @@ -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." ] }, diff --git a/examples/door-game/understone/world/data/monsters.json b/examples/door-game/understone/world/data/monsters.json index 7ec6a64e..e49d98b6 100644 --- a/examples/door-game/understone/world/data/monsters.json +++ b/examples/door-game/understone/world/data/monsters.json @@ -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", diff --git a/examples/door-game/understone/world/data/world.json b/examples/door-game/understone/world/data/world.json index ee4e9ea5..328d802b 100644 --- a/examples/door-game/understone/world/data/world.json +++ b/examples/door-game/understone/world/data/world.json @@ -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" } } diff --git a/examples/door-game/understone/world/loader.py b/examples/door-game/understone/world/loader.py index 5bb5756a..4238ae60 100644 --- a/examples/door-game/understone/world/loader.py +++ b/examples/door-game/understone/world/loader.py @@ -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.