mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(examples): Understone v0.7 — the deep, the satchel, the forge, rare beasts
The depth slice: four standing reasons to return past the daily reset. - The rung ladder: the dungeon is a descent fought one rung per turn, each guardian a fixed tier. A loss bounces you home but your depth PERSISTS — you re-enter where you left off. The Wyrm now gates on BOTH level AND reaching the floor (the deep has a bottom, and you must have touched it). - The satchel + the death-save: potions are CARRIED now (up to three), bought to the satchel, drunk with quaff. The heart of it: when any fight would kill the active fighter and they carry a draught, the strongest is drunk automatically — they survive standing at the potion's value, no bounce. This fires on EVERY fight (forest, rung, and the Wyrm itself — a potion carried to the climax is a real tactical choice); a Wyrm loss so saved is "driven back, alive but unproven," not devoured. The sleeping ambush victim never quaffs (they are asleep). combat.py stays pure — the satchel and the save live entirely in the façade. - The forge: the shop spends scaling gold to add a +1 edge to equipped weapon or armour, capped — the late-game gold sink. Swapping or selling the piece loses the edge with it (one centralized unequip clears the bonus and the plus so a stat can never go phantom). - Rare beasts: a few named foes prowl the forest via weighted selection, surfacing seldom; felling one is a public Herald flash and always yields a draught into the satchel. Rung guardians are never rare (fixed foes). Four new player columns; four new banded settings; dungeon_tiers extended to three rungs. Tests 283 -> 330; the death-save (all four paths), forge accounting across forge/buy/sell/legacy, rung math, and weighted rare selection all pinned, with the death-save and forge invariants revert-verified.
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,4 +6,4 @@ import understone
|
||||
|
||||
|
||||
def test_version_present() -> None:
|
||||
assert understone.__version__ == "0.6.0"
|
||||
assert understone.__version__ == "0.7.0"
|
||||
|
||||
@@ -205,3 +205,59 @@ def test_meta_round_trip(tmp_path: Path) -> None:
|
||||
assert store.get_meta("world_name") == "The Vale of Understone"
|
||||
assert store.get_meta("missing") is None
|
||||
store.close()
|
||||
|
||||
|
||||
def test_v0_7_depth_columns_round_trip(tmp_path: Path) -> None:
|
||||
"""The four v0.7 columns survive a store reopen: depth, satchel, two plusses."""
|
||||
store = _store(tmp_path)
|
||||
player = make_player(
|
||||
name="Delver",
|
||||
deepest_rung=2,
|
||||
satchel="minor_potion,greater_potion",
|
||||
weapon_plus=2,
|
||||
armor_plus=1,
|
||||
)
|
||||
store.upsert_player(player)
|
||||
store.commit()
|
||||
store.close()
|
||||
|
||||
reopened = _store(tmp_path)
|
||||
players, _ = reopened.load_all()
|
||||
loaded = players["Delver"]
|
||||
assert loaded == player # full equality across every column
|
||||
assert loaded.deepest_rung == 2
|
||||
assert loaded.satchel == "minor_potion,greater_potion"
|
||||
assert loaded.weapon_plus == 2
|
||||
assert loaded.armor_plus == 1
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_v0_7_depth_columns_default_for_legacy_rows(tmp_path: Path) -> None:
|
||||
"""A row written without the new columns loads them at their defaults.
|
||||
|
||||
The schema mutates in place (no migration, stamp stays 1), so the new
|
||||
columns carry DB-side defaults: a pre-v0.7 player row (inserted with the
|
||||
legacy column set) must read back deepest_rung 0, an empty satchel, and
|
||||
zero plusses rather than erroring.
|
||||
"""
|
||||
store = _store(tmp_path)
|
||||
store._conn.execute(
|
||||
"INSERT INTO players "
|
||||
"(name, x, y, hp, max_hp, level, xp, gold, atk, def_, weapon_id, armor_id, "
|
||||
" turns_left, turn_day, mode, at_location, created_at, last_seen, log_cursor, "
|
||||
" bestow_spent, bestow_day) "
|
||||
"VALUES ('Old', 5, 5, 20, 20, 1, 0, 20, 5, 1, 'rusty_dagger', 'cloth_tunic', "
|
||||
" 10, 0, 'tile', '', 't0', 't0', 0, 0, 0)",
|
||||
)
|
||||
store.commit()
|
||||
store.close()
|
||||
|
||||
reopened = _store(tmp_path)
|
||||
players, _ = reopened.load_all()
|
||||
old = players["Old"]
|
||||
assert old.deepest_rung == 0
|
||||
assert old.satchel == ""
|
||||
assert old.weapon_plus == 0
|
||||
assert old.armor_plus == 0
|
||||
assert reopened.get_meta("schema_version") == "1" # stamp unchanged
|
||||
reopened.close()
|
||||
|
||||
@@ -547,3 +547,113 @@ def test_overlong_monster_name_rejected(tmp_path: Path) -> None:
|
||||
_rewrite(pack / "monsters.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] name is 49 characters"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.7 loader rejections: the satchel/forge bands, rare_drop_item, monster weight
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rare_drop_item_unknown_rejected(tmp_path: Path) -> None:
|
||||
"""A rare_drop_item that names no item is rejected with the item-id message."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["rare_drop_item"] = "no_such_draught"
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match="rare_drop_item = 'no_such_draught' is not a known"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_rare_drop_item_non_consumable_rejected(tmp_path: Path) -> None:
|
||||
"""A rare_drop_item that names a weapon (not a consumable) is rejected.
|
||||
|
||||
The drop goes straight into the satchel to be quaffed, so a weapon or
|
||||
armour id is incoherent — the loader pins the slot.
|
||||
"""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["rare_drop_item"] = "iron_sword" # a weapon, not a draught
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match="rare_drop_item = 'iron_sword' must be a consumable"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_satchel_max_out_of_band_rejected(tmp_path: Path) -> None:
|
||||
"""satchel_max above its 1..10 band is a load error."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["satchel_max"] = 11
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match=r"satchel_max = 11 is out of band \(1\.\.10\)"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_forge_max_plus_out_of_band_rejected(tmp_path: Path) -> None:
|
||||
"""forge_max_plus above its 0..10 band is a load error."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["forge_max_plus"] = 11
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match=r"forge_max_plus = 11 is out of band \(0\.\.10\)"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_forge_base_cost_out_of_band_rejected(tmp_path: Path) -> None:
|
||||
"""forge_base_cost below its floor of 1 is a load error."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["forge_base_cost"] = 0
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match=r"forge_base_cost = 0 is out of band \(1\.\.10000\)"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_monster_zero_weight_rejected(tmp_path: Path) -> None:
|
||||
"""A monster weight of 0 is rejected (the weighted pick needs a positive total)."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: list[dict[str, Any]]) -> None:
|
||||
data[0]["weight"] = 0
|
||||
|
||||
_rewrite(pack / "monsters.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match=r"monsters\.json\[0\] weight must be > 0"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_shipped_pack_carries_rares_and_weights() -> None:
|
||||
"""The shipped pack parses the v0.7 rare beasts with their low weights."""
|
||||
world = load_world(SHIPPED)
|
||||
rares = [m for m in world.monsters if m.rare]
|
||||
names = {m.name for m in rares}
|
||||
assert names == {"the Gilded Stag", "the Hollow Knight"}
|
||||
assert all(m.weight == 1 for m in rares) # rares surface seldom
|
||||
# The rare_drop_item resolves to a consumable.
|
||||
drop = world.item_by_id(world.settings.rare_drop_item)
|
||||
assert drop is not None and drop.slot.value == "consumable"
|
||||
# The new economy settings land on their shipped values.
|
||||
assert world.settings.satchel_max == 3
|
||||
assert world.settings.forge_base_cost == 60
|
||||
assert world.settings.forge_max_plus == 3
|
||||
assert world.settings.dungeon_tiers == (3, 4, 5)
|
||||
|
||||
|
||||
def test_monster_weight_and_rare_default_when_omitted(tmp_path: Path) -> None:
|
||||
"""A monster spec without weight/rare loads as weight 10, rare False.
|
||||
|
||||
Both fields are optional with defaults, so an unannotated common monster
|
||||
(the shipped Field Rat) parses to the default weight and the non-rare flag.
|
||||
"""
|
||||
world = load_world(SHIPPED)
|
||||
rat = next(m for m in world.monsters if m.name == "Field Rat")
|
||||
assert rat.weight == 10 # the default biasing weight
|
||||
assert rat.rare is False
|
||||
|
||||
@@ -42,10 +42,18 @@ def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
|
||||
|
||||
|
||||
def _at_dungeon(game: Game, name: str) -> object:
|
||||
"""Place an already-joined player inside the dungeon menu."""
|
||||
"""Place an already-joined player inside the dungeon menu, at the deep floor.
|
||||
|
||||
The challenge verb now gates on depth as well as level: the Wyrm will not
|
||||
stir until the hero has plumbed the deep to its floor. These challenge
|
||||
tests exercise the win/lose/flee paths, not the gate, so the helper puts
|
||||
the hero at the bottom (deepest_rung == the rung count). The depth gate
|
||||
itself is exercised by the dedicated tests in test_descend.py.
|
||||
"""
|
||||
player = game.players[name]
|
||||
player.mode = Mode.MENU
|
||||
player.at_location = "dungeon"
|
||||
player.deepest_rung = len(game.world.settings.dungeon_tiers)
|
||||
return player
|
||||
|
||||
|
||||
@@ -266,6 +274,97 @@ def test_challenge_loss_bounces_and_heralds(tmp_path: Path, clock: object) -> No
|
||||
assert "lays you low" in out.lower() or "wyrm" in out.lower()
|
||||
|
||||
|
||||
def _doomed_wyrm_challenger(game: Game, name: str) -> object:
|
||||
"""Stand *name* at the floor, wyrm-eligible, and doomed to a GRINDING loss.
|
||||
|
||||
The stats — modest atk and def, hp 50 below max_hp 80, well off the spawn —
|
||||
make the Wyrm bout a genuine multi-round lethal loss (not a one-shot where
|
||||
no blow lands before the save). hp 50 is none of the potion heal values
|
||||
(15/40/70), so a death-save that sets hp to the potion's heal is unmistakable.
|
||||
"""
|
||||
player = _at_dungeon(game, name)
|
||||
player.level = game.world.settings.wyrm_min_level
|
||||
player.x, player.y = 35, 25 # away from the spawn (a save never moves them)
|
||||
player.atk, player.def_, player.hp, player.max_hp = 6, 12, 50, 80
|
||||
return player
|
||||
|
||||
|
||||
def test_challenge_loss_with_potion_survives_no_legacy_reset(tmp_path: Path, clock: object) -> None:
|
||||
"""A lethal Wyrm bout with a potion is SURVIVED — no bounce, no legacy reset.
|
||||
|
||||
The universal death-save reaches the Wyrm: a carried draught is drunk instead
|
||||
of the devouring. A save is NOT a win, so NOTHING resets — level, gold, and
|
||||
``deepest_rung`` all stand — and it is NOT the devouring either, so the hero
|
||||
keeps their place at the dungeon. The PUBLIC beat is the survival one
|
||||
(``wyrm_flee``, "driven back, alive but unproven"), NEVER "devoured". The
|
||||
turn is still spent and the draught is consumed.
|
||||
"""
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brak")
|
||||
player = _doomed_wyrm_challenger(game, "Brak")
|
||||
potion = game.world.item_by_id("greater_potion")
|
||||
assert potion is not None
|
||||
game._satchel_set(player, ["greater_potion"])
|
||||
floor = len(game.world.settings.dungeon_tiers)
|
||||
spawn = game.world.spawn
|
||||
before_turns = player.turns_left
|
||||
before_level, before_gold = player.level, player.gold
|
||||
events_before = len(game.events)
|
||||
|
||||
out = game.action("Brak", "challenge", "", "")
|
||||
|
||||
# Survived standing: hp at the potion's value, no bounce, draught spent.
|
||||
assert player.hp == min(player.max_hp, potion.heal)
|
||||
assert (player.x, player.y) != spawn # NOT bounced to the spawn
|
||||
assert player.mode is Mode.MENU # still standing at the dungeon
|
||||
assert game._satchel_list(player) == [] # the draught was spent
|
||||
assert "death's edge" in out.lower() # the spliced survival line
|
||||
assert player.turns_left == before_turns - 1 # the challenge still cost a turn
|
||||
# No win, so NO legacy reset: level, gold, and depth all stand.
|
||||
assert player.wins == 0
|
||||
assert player.level == before_level
|
||||
assert player.gold == before_gold
|
||||
assert player.deepest_rung == floor # depth untouched (no reset to 0)
|
||||
# The PUBLIC beat is the survival one, NOT the devouring.
|
||||
assert len(game.events) == events_before + 1
|
||||
beat = game.events[-1]
|
||||
assert beat.kind == "wyrm_flee"
|
||||
assert beat.kind != "wyrm_lose"
|
||||
assert "fled" in beat.text.lower() or "ran" in beat.text.lower()
|
||||
|
||||
|
||||
def test_challenge_loss_potion_negative_without_save_devours(
|
||||
tmp_path: Path, clock: object, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""NEGATIVE TEST: with the death-save disabled, the same potion-carrier is devoured.
|
||||
|
||||
The mechanical equivalent of reverting the added ``_death_save`` call in
|
||||
``_wyrm_lost``: we stub ``_death_save`` to always decline, then run the exact
|
||||
scenario of the survival test. The potion-carrier must now bounce to the
|
||||
spawn at 1 HP with the draught UNSPENT and the PUBLIC beat back to
|
||||
``wyrm_lose`` (devoured) — proving the death-save (not some other path) is
|
||||
what saves them at the Wyrm. Restoring the real method (automatic when the
|
||||
patch lifts) restores the survival behaviour.
|
||||
"""
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brak")
|
||||
player = _doomed_wyrm_challenger(game, "Brak")
|
||||
game._satchel_set(player, ["greater_potion"])
|
||||
floor = len(game.world.settings.dungeon_tiers)
|
||||
spawn = game.world.spawn
|
||||
|
||||
monkeypatch.setattr(Game, "_death_save", lambda self, pl, lines: False)
|
||||
out = game.action("Brak", "challenge", "", "")
|
||||
|
||||
assert player.hp == 1 # devoured, not saved
|
||||
assert (player.x, player.y) == spawn
|
||||
assert player.mode is Mode.TILE
|
||||
assert player.deepest_rung == floor # a defeat keeps depth (no reset, no advance)
|
||||
assert game._satchel_list(player) == ["greater_potion"] # the draught is UNSPENT
|
||||
assert "death's edge" not in out.lower() # no save, no dramatic line
|
||||
assert game.events[-1].kind == "wyrm_lose" # the devouring beat, not the survival one
|
||||
|
||||
|
||||
def test_challenge_stalemate_counts_as_flight(tmp_path: Path, clock: object) -> None:
|
||||
"""A 50-round stalemate resolves as a flight: a wyrm_flee news beat.
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Understone — a BBS-style ANSI door game served over MCP."""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
@@ -271,7 +271,8 @@ The cross-references the loader enforces:
|
||||
`locations.json`, and must sit on walkable terrain;
|
||||
* `settings.starting_weapon` / `starting_armor` must be ids from
|
||||
`items.json`; `settings.boss_monster` must be an id from `monsters.json`
|
||||
that is flagged `"boss": true`;
|
||||
that is flagged `"boss": true`; `settings.rare_drop_item` must be an id from
|
||||
`items.json` whose `slot` is `consumable`;
|
||||
* every tier in `settings.dungeon_tiers` must be backed by a NON-boss monster;
|
||||
* every zone's tier band must overlap at least one monster tier.
|
||||
|
||||
@@ -313,6 +314,19 @@ The boss adds an `id` and `"boss": true`, and is referenced by
|
||||
"xp": 400, "gold": 250, "boss": true, "id": "wyrm_below"}
|
||||
```
|
||||
|
||||
Two optional fields tune random forest encounters. `weight` (default `10`,
|
||||
must be `> 0`) biases the weighted draw within a zone band — a low weight
|
||||
surfaces seldom — and `rare` (default `false`) marks a named beast that, on
|
||||
its kill, fires a public Herald flash and drops the pack's `rare_drop_item`
|
||||
into the slayer's satchel. Rung guardians ignore both (a rung always takes the
|
||||
FIRST monster of its tier, never a weighted roll), so a rare should not be the
|
||||
first entry of a tier that backs a `dungeon_tiers` rung.
|
||||
|
||||
```json
|
||||
{"tier": 2, "name": "the Gilded Stag", "hp": 16, "atk": 6, "def": 2,
|
||||
"xp": 40, "gold": 60, "weight": 1, "rare": true}
|
||||
```
|
||||
|
||||
### `items.json`
|
||||
|
||||
A list of equipment and consumables. `slot` is `weapon`, `armor`, or
|
||||
@@ -335,9 +349,10 @@ An object keyed by location key. Each entry is a building kind with a menu of
|
||||
}
|
||||
```
|
||||
|
||||
The verbs the engine understands are `rest` (inn), `buy`/`sell` (shop), `heal`
|
||||
(healer), `descend`/`challenge` (dungeon), and `leave`. Give each building the
|
||||
menu that matches its role.
|
||||
The verbs the engine understands are `rest` (inn), `buy`/`sell`/`forge` (shop),
|
||||
`heal` (healer), `descend`/`challenge` (dungeon), and `leave`. (`quaff` is legal
|
||||
anywhere and needs no menu entry.) Give each building the menu that matches its
|
||||
role.
|
||||
|
||||
### `events.json`
|
||||
|
||||
@@ -434,9 +449,24 @@ boss tier must NOT appear in `settings.dungeon_tiers`: the gauntlet excludes
|
||||
boss monsters, so a boss-only rung would be unfillable — back every dungeon
|
||||
tier with at least one ordinary monster.
|
||||
|
||||
**The deep, the satchel, and the forge.** `dungeon_tiers` is now a RUNG LADDER
|
||||
fought one rung per `descend` — list the tiers shallow-to-deep, and make it long
|
||||
enough to feel like a journey (the Vale uses three). The Wyrm gates on reaching
|
||||
the floor as well as on level. Size the satchel with `satchel_max` (the Vale
|
||||
carries 3) — it is the death-save reserve, so keep it small. The forge is the
|
||||
late-game gold sink: `forge_base_cost` is the price of a +1 edge and scales up
|
||||
each tier (`base * (current_plus + 1)`), capped at `forge_max_plus`; price it so
|
||||
a fully-forged piece is a multi-day saving.
|
||||
|
||||
**Rare beasts.** A rare monster is a small legend: give it a low `weight` so it
|
||||
surfaces seldom, stats and rewards a clear notch above its tier, and remember it
|
||||
always drops `rare_drop_item` (a consumable) into the satchel. Keep rares OFF
|
||||
the first slot of any `dungeon_tiers` tier, or they would become a fixed rung
|
||||
guardian instead of a rare roll.
|
||||
|
||||
**Location menus.** Give each building only the actions it can honour. An inn
|
||||
that offers `buy` but no shop logic will confuse the narrator; match the menu
|
||||
to the building's role.
|
||||
to the building's role. The shop verbs are `buy`, `sell`, and `forge`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -61,6 +61,14 @@ class Player:
|
||||
post_day: int = 0
|
||||
gambles: int = 0
|
||||
gamble_day: int = 0
|
||||
# v0.7 "depth below" retention columns: how far the dungeon has been
|
||||
# plumbed (0 = never descended; N = cleared rung N, 1-indexed), the
|
||||
# comma-joined carried-potion satchel ('' = empty), and the enhancement
|
||||
# plus on whichever weapon/armour is CURRENTLY equipped in each slot.
|
||||
deepest_rung: int = 0
|
||||
satchel: str = ""
|
||||
weapon_plus: int = 0
|
||||
armor_plus: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -81,6 +89,12 @@ class Monster:
|
||||
gold: int
|
||||
monster_id: str = ""
|
||||
boss: bool = False
|
||||
# v0.7 weighted forest encounters: ``weight`` biases the random pick (a
|
||||
# low weight surfaces seldom), ``rare`` marks a named beast that fires a
|
||||
# public Herald flash and drops a guaranteed draught on the kill. Rung
|
||||
# guardians ignore both (a rung is a fixed foe, never a weighted roll).
|
||||
weight: int = 10
|
||||
rare: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -183,3 +197,10 @@ class Settings:
|
||||
post_daily_cap: int
|
||||
gamble_max_bet: int
|
||||
gamble_daily_cap: int
|
||||
# v0.7 "depth below": the carried-potion satchel size, the forge cost
|
||||
# ladder (base * (current_plus + 1)) and its enhancement ceiling, and the
|
||||
# consumable item a rare beast is guaranteed to drop on its kill.
|
||||
satchel_max: int
|
||||
forge_base_cost: int
|
||||
forge_max_plus: int
|
||||
rare_drop_item: str
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -113,12 +113,39 @@ WANDERING THE FOREST (the texture of a walk)
|
||||
them as the quiet texture of travelling, and watch the lore: it whispers of
|
||||
something coiled beneath the dungeon.
|
||||
|
||||
DELVING DEEP (the reasons to come back)
|
||||
Beneath the daily reset are four standing draws that reward a returning hero.
|
||||
* THE RUNG LADDER. The dungeon is a ladder of guardians fought one rung per
|
||||
'descend' (each costs a daily turn). A descent faces the NEXT rung past your
|
||||
deepest; a win advances your depth and you climb back out, a loss bounces
|
||||
you home but your depth PERSISTS — you re-enter where you left off. Reaching
|
||||
the last rung opens the Wyrm's door (the depth gate above). Narrate the deep
|
||||
as a slow, earned descent, a rung at a time.
|
||||
* THE SATCHEL AND THE DEATH-SAVE. A small satchel carries a few potions
|
||||
(buying one at the shop now STOWS it instead of drinking it). 'quaff'
|
||||
(anywhere, no turn) drinks the strongest. The heart of it: if a fight would
|
||||
KILL the active fighter and they carry a potion, the strongest is drunk
|
||||
AUTOMATICALLY — they survive standing at the potion's value, no bounce, no
|
||||
spawn reset. Play that beat big: the elixir burning down their throat at the
|
||||
edge of death. (A sleeping ambush victim never auto-quaffs — they are
|
||||
asleep.)
|
||||
* THE FORGE. The shop's forge spends gold to add a +1 edge to the equipped
|
||||
weapon or armour ('forge' target="weapon"/"armour"), up to a cap, each tier
|
||||
dearer than the last — the late-game gold sink. Swapping or selling that
|
||||
piece loses the edge with it.
|
||||
* RARE BEASTS. A few named beasts prowl the forest, surfacing seldom. Felling
|
||||
one is loud — a public Herald flash — and it always guards a draught that
|
||||
drops into the satchel (if there is room). Treat a rare kill as a small
|
||||
legend in itself.
|
||||
|
||||
THE WYRM BELOW (the endgame, and how to win)
|
||||
Deep under the dungeon sleeps the Wyrm Below — a fixed, fearsome boss and
|
||||
the ONLY win condition. At the dungeon, a sufficiently seasoned hero may
|
||||
'challenge' it (door_action action="challenge"). Under the level threshold,
|
||||
the server turns them away in-fiction; once allowed, the challenge spends a
|
||||
daily turn and resolves in one call, like a fight.
|
||||
'challenge' it (door_action action="challenge"). The Wyrm gates on BOTH
|
||||
level AND depth: an under-level hero is turned away first, and even a high
|
||||
hero who has not plumbed the deep to its floor (see DELVING DEEP) is told the
|
||||
Wyrm will not stir. Once both are met, the challenge spends a daily turn and
|
||||
resolves in one call, like a fight.
|
||||
* On victory the hero FREES THE VALE. The triumph is heralded to everyone,
|
||||
the run is carved into the Hall of Legends (shown by door_rank), and the
|
||||
hero is reborn in a classic-door-game-style legacy reset: level, gold, gear and stats
|
||||
@@ -170,9 +197,10 @@ TOOL CHEAT-SHEET
|
||||
door_move(player, ...) Walk the overworld (free). steps="NNEE" or
|
||||
heading="east" + distance=3 (max 8 per call).
|
||||
door_action(player, action) Context verb: fight, flee, ambush (a rival),
|
||||
rest, buy, sell, heal, gamble (dice at the
|
||||
inn), descend, challenge (the Wyrm), post (a
|
||||
note for another player), leave.
|
||||
rest, buy, sell, forge (a +1 edge), heal,
|
||||
gamble (dice at the inn), descend (one rung),
|
||||
challenge (the Wyrm), post (a note), quaff (a
|
||||
carried potion), leave.
|
||||
door_log(player) Read the Understone Herald (the shared feed).
|
||||
door_rank(player) The leaderboard + Hall of Legends (★ = wins).
|
||||
door_bestow(player, reason...) Grant a little gold/healing for a story beat.
|
||||
@@ -344,22 +372,29 @@ def door_action(
|
||||
'fight' or 'flee' a wandering monster (fighting spends one daily turn), or
|
||||
'ambush' a named rival who has not yet acted today — a sleeping-rival
|
||||
robbery in the spirit of the classic door-game player-kill (target=<name>, spends a turn).
|
||||
Inside a building: 'rest' (inn), 'buy'/'sell' (shop), 'heal' (healer), or
|
||||
'leave'. At the inn you may also 'gamble' a stake of gold at dice
|
||||
(amount=<gold>). At the dungeon: 'descend' the gauntlet, or 'challenge' the
|
||||
Wyrm Below — the endgame boss and the only way to win. The challenge is
|
||||
gated by level (under-level heroes are turned away in-fiction) and, once
|
||||
allowed, spends a daily turn and resolves in a single call like a fight:
|
||||
a victory frees the Vale and begins a new life (see door_help), a defeat
|
||||
bounces you home. Anywhere, 'post' leaves a private note for another player
|
||||
(target=<name>, text=<message>) that they read on their next door_log;
|
||||
posting costs no turn. An illegal verb returns the verbs valid right here.
|
||||
Inside a building: 'rest' (inn), 'buy'/'sell'/'forge' (shop), 'heal'
|
||||
(healer), or 'leave'. At the inn you may also 'gamble' a stake of gold at
|
||||
dice (amount=<gold>). Buying a potion now stows it in your satchel rather
|
||||
than drinking it; 'forge' (target="weapon"/"armour", at the shop) spends
|
||||
gold to add a +1 edge to your equipped gear, up to a cap. At the dungeon:
|
||||
'descend' ONE rung of the deep — the next guardian past your deepest, one
|
||||
per turn — or 'challenge' the Wyrm Below, the endgame boss and the only way
|
||||
to win. Descending a rung advances your depth; reaching the floor opens the
|
||||
Wyrm's door. The challenge is gated by BOTH level and depth (you must have
|
||||
plumbed the deep to its floor) and, once allowed, spends a daily turn and
|
||||
resolves in a single call like a fight: a victory frees the Vale and begins
|
||||
a new life (see door_help), a defeat bounces you home. Anywhere and at no
|
||||
turn cost: 'post' leaves a private note for another player (target=<name>,
|
||||
text=<message>) read on their next door_log, and 'quaff' drinks the
|
||||
strongest potion from your satchel. An illegal verb returns the verbs valid
|
||||
right here.
|
||||
|
||||
Args:
|
||||
player: The adventurer's name.
|
||||
action: The verb to attempt (fight, flee, ambush, rest, buy, sell,
|
||||
heal, gamble, descend, challenge, post, leave).
|
||||
target: The other player's name for 'ambush' and 'post'.
|
||||
forge, heal, gamble, descend, challenge, post, quaff, leave).
|
||||
target: The other player's name for 'ambush'/'post', or the slot
|
||||
("weapon"/"armour") for 'forge'.
|
||||
item: For shop 'buy', the item id to purchase.
|
||||
text: For 'post', the note left for the target (<= 120 characters).
|
||||
amount: For inn 'gamble', the gold wagered on the dice.
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
"name": "Gravel & Sons Outfitters",
|
||||
"glyph": "$",
|
||||
"color": "town",
|
||||
"actions": ["buy", "sell", "leave"],
|
||||
"actions": ["buy", "sell", "forge", "leave"],
|
||||
"flavor": [
|
||||
"Racks of steel and leather line the walls.",
|
||||
"Buying a weapon or armour equips it at once.",
|
||||
"Potions are quaffed on purchase (no pack to carry).",
|
||||
"Potions go into your satchel, to quaff when the need is dire.",
|
||||
"The forge glows: bring coin to better your blade or your guard.",
|
||||
"Old gear sells back for half its price."
|
||||
]
|
||||
},
|
||||
|
||||
@@ -35,6 +35,17 @@
|
||||
"xp": 20,
|
||||
"gold": 9
|
||||
},
|
||||
{
|
||||
"tier": 2,
|
||||
"name": "the Gilded Stag",
|
||||
"hp": 16,
|
||||
"atk": 6,
|
||||
"def": 2,
|
||||
"xp": 40,
|
||||
"gold": 60,
|
||||
"weight": 1,
|
||||
"rare": true
|
||||
},
|
||||
{
|
||||
"tier": 3,
|
||||
"name": "Forest Wolf",
|
||||
@@ -53,6 +64,17 @@
|
||||
"xp": 38,
|
||||
"gold": 16
|
||||
},
|
||||
{
|
||||
"tier": 3,
|
||||
"name": "the Hollow Knight",
|
||||
"hp": 30,
|
||||
"atk": 11,
|
||||
"def": 4,
|
||||
"xp": 80,
|
||||
"gold": 110,
|
||||
"weight": 1,
|
||||
"rare": true
|
||||
},
|
||||
{
|
||||
"tier": 4,
|
||||
"name": "Cave Troll",
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
"def": 1
|
||||
},
|
||||
"bestow_daily_budget": 25,
|
||||
"dungeon_tiers": [4, 5],
|
||||
"dungeon_tiers": [3, 4, 5],
|
||||
"boss_monster": "wyrm_below",
|
||||
"wyrm_min_level": 6,
|
||||
"ambush_min_level": 3,
|
||||
@@ -122,6 +122,10 @@
|
||||
"ambush_gold_pct": 25,
|
||||
"post_daily_cap": 5,
|
||||
"gamble_max_bet": 50,
|
||||
"gamble_daily_cap": 5
|
||||
"gamble_daily_cap": 5,
|
||||
"satchel_max": 3,
|
||||
"forge_base_cost": 60,
|
||||
"forge_max_plus": 3,
|
||||
"rare_drop_item": "greater_potion"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ SETTINGS_BANDS: dict[str, tuple[int, int | None]] = {
|
||||
"post_daily_cap": (0, 50),
|
||||
"gamble_max_bet": (1, 10000),
|
||||
"gamble_daily_cap": (0, 100),
|
||||
"satchel_max": (1, 10),
|
||||
"forge_base_cost": (1, 10000),
|
||||
"forge_max_plus": (0, 10),
|
||||
}
|
||||
|
||||
# Per-kind amount bands for the overworld event table (inclusive).
|
||||
@@ -215,6 +218,9 @@ def _load_monsters(root: Path) -> list[Monster]:
|
||||
for label, val in (("atk", atk), ("def", def_), ("xp", xp), ("gold", gold)):
|
||||
if val < 0:
|
||||
raise WorldLoadError(f"{where} {label} must be >= 0, got {val}")
|
||||
weight = int(spec.get("weight", 10))
|
||||
if weight <= 0:
|
||||
raise WorldLoadError(f"{where} weight must be > 0, got {weight}")
|
||||
out.append(
|
||||
Monster(
|
||||
tier=int(_require(spec, "tier", where)),
|
||||
@@ -226,6 +232,8 @@ def _load_monsters(root: Path) -> list[Monster]:
|
||||
gold=gold,
|
||||
monster_id=str(spec.get("id", "")),
|
||||
boss=bool(spec.get("boss", False)),
|
||||
weight=weight,
|
||||
rare=bool(spec.get("rare", False)),
|
||||
)
|
||||
)
|
||||
if not out:
|
||||
@@ -594,6 +602,7 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons
|
||||
|
||||
dungeon_tiers = _decode_dungeon_tiers(spec, monsters)
|
||||
boss_monster = _decode_boss_monster(spec, monsters)
|
||||
rare_drop_item = _decode_rare_drop_item(spec, items)
|
||||
|
||||
return Settings(
|
||||
daily_turns=values["daily_turns"],
|
||||
@@ -619,6 +628,10 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons
|
||||
post_daily_cap=values["post_daily_cap"],
|
||||
gamble_max_bet=values["gamble_max_bet"],
|
||||
gamble_daily_cap=values["gamble_daily_cap"],
|
||||
satchel_max=values["satchel_max"],
|
||||
forge_base_cost=values["forge_base_cost"],
|
||||
forge_max_plus=values["forge_max_plus"],
|
||||
rare_drop_item=rare_drop_item,
|
||||
)
|
||||
|
||||
|
||||
@@ -642,6 +655,29 @@ def _decode_boss_monster(spec: dict[str, Any], monsters: list[Monster]) -> str:
|
||||
return boss_id
|
||||
|
||||
|
||||
def _decode_rare_drop_item(spec: dict[str, Any], items: list[Item]) -> str:
|
||||
"""Resolve and validate the item a rare beast drops on its kill.
|
||||
|
||||
The id must name an item in the pack AND that item must be a consumable
|
||||
(it goes straight into the satchel to be quaffed later, so a weapon or
|
||||
armour id would be incoherent). Mirrors the ``starting_weapon`` check but
|
||||
adds the slot constraint.
|
||||
"""
|
||||
drop_id = str(_require(spec, "rare_drop_item", "world.json settings"))
|
||||
by_id = {it.item_id: it for it in items}
|
||||
item = by_id.get(drop_id)
|
||||
if item is None:
|
||||
raise WorldLoadError(
|
||||
f"world.json settings.rare_drop_item = {drop_id!r} is not a known item id"
|
||||
)
|
||||
if item.slot is not Slot.CONSUMABLE:
|
||||
raise WorldLoadError(
|
||||
f"world.json settings.rare_drop_item = {drop_id!r} must be a consumable item, "
|
||||
f"not {item.slot.value!r}"
|
||||
)
|
||||
return drop_id
|
||||
|
||||
|
||||
def _decode_dungeon_tiers(spec: dict[str, Any], monsters: list[Monster]) -> tuple[int, ...]:
|
||||
"""Parse the ordered dungeon-gauntlet tier ladder, one foe per tier.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user