feat(examples): Understone v0.10 — the satchel, the ore-forge, and the vault

A game-loop mechanics patch: the satchel becomes a real stacking inventory,
forging now demands ore won in combat (not just gold), and a vault lets a
hero protect coin from ambush.

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

Four new banded settings (forge_ore_item, forge_ore_per_plus,
ore_dungeon_drop, ore_forest_chance); both worlds gained an ore item.
Schema mutated in place (banked column, satchel re-encoding) — pre-1.0, no
migration by design; a real migration story is owed at 1.0. Tests 382 ->
419; the vault-survives-rebirth invariant and the codec are revert-verified.
This commit is contained in:
Patrick Buckley
2026-06-12 23:48:00 -07:00
parent 917e391b1f
commit 393a6fc2b2
28 changed files with 1278 additions and 151 deletions
+12 -4
View File
@@ -45,9 +45,13 @@ A run is a little RPG loop, played a bit each day:
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.
down your throat at death's edge. Clearing a rung also yields **forge ore**,
which rides the satchel (a won forest fight sometimes turns up a little, too).
- **Forge an edge — with gold AND ore.** At the shop's **forge** you can add a
+1 edge to your equipped weapon or armour, up to a cap, each step dearer than
the last. A step costs gold *and* the ore you won in the deep — so the forge is
fed by descending, not just by a fat purse. Watch, too, 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 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
@@ -65,6 +69,10 @@ A run is a little RPG loop, played a bit each day:
feed. `post` a private note another player reads on their next visit (it
never reaches the public Herald). Or `gamble` a little gold at the inn's dice
against the house. Ambush spends a turn; mail and dice do not.
- **Bank your coin.** The inn keeps a strongbox: `deposit` gold into the
**vault** and `withdraw` it later (no turn either way). Banked gold is **safe
from ambush** — a sleeping-robber only ever lifts what you carry — and it is
the one thing that **survives a Wyrm-win reset**, carrying wealth across runs.
## Installation
@@ -254,7 +262,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, forge (a +1 edge), heal, gamble (inn dice), descend (one rung), challenge (the Wyrm), post (mail another player), quaff (a carried potion), leave. |
| `door_action` | Context verbs: fight, flee, ambush (a rival), rest, deposit/withdraw (the inn vault), buy, sell, forge (a +1 edge, gold + ore), heal, gamble (inn dice), descend (one rung), challenge (the Wyrm), post (mail another player), quaff (a carried potion), leave. |
| `door_log` | The Understone Herald — the shared feed of notable deeds. |
| `door_rank` | The leaderboard, plus the Hall of Legends (★ marks Wyrm kills). |
| `door_bestow` | Game-master grant of a little gold/healing for a story beat. |
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "understone"
version = "0.9.0"
version = "0.10.0"
description = "Understone — a BBS-style ANSI door game served over MCP."
requires-python = ">=3.11"
license = "Apache-2.0"
+36
View File
@@ -7,6 +7,7 @@ fixtures that load the shipped world and build the game façade.
from __future__ import annotations
from collections import Counter
from datetime import UTC, datetime
from typing import TYPE_CHECKING
@@ -29,6 +30,8 @@ from understone.engine.world import World
if TYPE_CHECKING:
from collections.abc import Callable
from understone.game import Game
# ---------------------------------------------------------------------------
# Terrain kinds for synthetic test worlds
# ---------------------------------------------------------------------------
@@ -68,6 +71,10 @@ DEFAULT_SETTINGS = Settings(
forge_base_cost=60,
forge_max_plus=3,
rare_drop_item="minor_potion",
forge_ore_item="iron_ore",
forge_ore_per_plus=1,
ore_dungeon_drop=2,
ore_forest_chance=0.2,
watch_theme="phosphor",
)
@@ -102,6 +109,10 @@ def make_settings(**overrides: object) -> Settings:
"forge_base_cost": DEFAULT_SETTINGS.forge_base_cost,
"forge_max_plus": DEFAULT_SETTINGS.forge_max_plus,
"rare_drop_item": DEFAULT_SETTINGS.rare_drop_item,
"forge_ore_item": DEFAULT_SETTINGS.forge_ore_item,
"forge_ore_per_plus": DEFAULT_SETTINGS.forge_ore_per_plus,
"ore_dungeon_drop": DEFAULT_SETTINGS.ore_dungeon_drop,
"ore_forest_chance": DEFAULT_SETTINGS.ore_forest_chance,
"watch_theme": DEFAULT_SETTINGS.watch_theme,
}
base.update(overrides)
@@ -197,6 +208,7 @@ def _default_items() -> list[Item]:
Item("cloth_tunic", "Cloth Tunic", Slot.ARMOR, 0, 1, 0, 0),
Item("leather_armor", "Leather Armor", Slot.ARMOR, 0, 3, 0, 50),
Item("minor_potion", "Minor Potion", Slot.CONSUMABLE, 0, 0, 15, 12),
Item("iron_ore", "Iron Ore", Slot.MATERIAL, 0, 0, 0, 0),
]
@@ -214,6 +226,30 @@ def utc(year: int, month: int, day: int, hour: int = 0, minute: int = 0) -> date
return datetime(year, month, day, hour, minute, tzinfo=UTC)
# ---------------------------------------------------------------------------
# Satchel test helpers (the v0.10 stack encoding)
# ---------------------------------------------------------------------------
# The satchel is stack-based ("id:qty"); these wrap the game façade's stack
# helpers so a test can seed/read a bag as a flat id list (duplicate ids
# collapse to one stack), keeping the assertions readable. Shared by the
# descend and Wyrm suites.
def set_satchel(game: Game, player: object, ids: list[str]) -> None:
"""Seed *player*'s satchel from a flat id list (duplicates -> one stack qty)."""
counts = Counter(ids)
stacks = [(item_id, counts[item_id]) for item_id in dict.fromkeys(ids)]
game._satchel_set_stacks(player, stacks) # type: ignore[arg-type]
def satchel_ids(game: Game, player: object) -> list[str]:
"""Return the satchel as a flat id list, each stack expanded by its qty."""
out: list[str] = []
for item_id, qty in game._satchel_stacks(player): # type: ignore[arg-type]
out.extend([item_id] * qty)
return out
@pytest.fixture
def small_world() -> World:
"""An 11x11 all-grass world with the default content tables."""
+29 -6
View File
@@ -150,22 +150,45 @@ def test_cli_newpack_authoring_md_has_width_rule_and_live_palette(tmp_path: Path
assert f"`{glyph}`" in manual, f"palette glyph {glyph!r} missing from manual"
def test_cli_newpack_authoring_md_documents_v07_action_sets(tmp_path: Path) -> None:
"""AUTHORING.md documents each building's real verb menu, gamble included.
def test_cli_newpack_authoring_md_documents_action_sets(tmp_path: Path) -> None:
"""AUTHORING.md documents each building's real verb menu.
The v0.8 doc fix: the inn's live `gamble` verb was previously absent, and
the per-building menus are now an explicit table. This pins the table rows
and the "quaff anywhere" note so a doc regression trips.
The per-building menus are an explicit table: the inn's `gamble` (v0.8) and
the v0.10 vault verbs `deposit`/`withdraw`, the shop's `forge`, and so on.
This pins the table rows and the "quaff anywhere" note so a doc regression
trips.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "| `inn` | `rest`, `gamble`, `leave` |" in manual
assert "| `inn` | `rest`, `deposit`, `withdraw`, `gamble`, `leave` |" in manual
assert "| `shop` | `buy`, `sell`, `forge`, `leave` |" in manual
assert "| `healer` | `heal`, `leave` |" in manual
assert "| `dungeon` | `descend`, `challenge`, `leave` |" in manual
assert "`quaff`" in manual and "legal **anywhere**" in manual
# The vault is described where its verbs are listed.
assert "VAULT" in manual and "SAFE from ambush" in manual
def test_cli_newpack_authoring_md_documents_ore_forge(tmp_path: Path) -> None:
"""AUTHORING.md documents the v0.10 ore-gated forge: material slot + settings.
The forge ore is a `material` item earned in combat; the four ore settings
(item, per-plus, dungeon drop, forest chance) are documented, and the band
figures are generated from the live loader so they cannot drift.
"""
dest = tmp_path / "mypack"
cli.cli_newpack(dest, out=StringIO(), err=StringIO())
manual = (dest / "AUTHORING.md").read_text(encoding="utf-8")
assert "`material`" in manual # the new slot
assert "forge_ore_item" in manual
assert "ore_forest_chance" in manual # the float setting (prose, not the band table)
# The two banded ore settings carry their LIVE bands.
lo, hi = loader.SETTINGS_BANDS["ore_dungeon_drop"]
assert f"`{lo}..{hi}`" in manual
assert "earns in combat" in manual or "earned in combat" in manual
def test_cli_newpack_authoring_md_states_color_advisory_and_spawn_walkable(
+239 -37
View File
@@ -33,7 +33,14 @@ from pathlib import Path
import pytest
from tests.conftest import fixed_clock, make_monster, make_world, utc
from tests.conftest import (
fixed_clock,
make_monster,
make_world,
satchel_ids,
set_satchel,
utc,
)
from understone.engine.models import Mode, Zone
from understone.engine.rng import GameRNG
from understone.game import Game
@@ -42,6 +49,11 @@ from understone.world.loader import load_world
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
# Module-local aliases for the shared satchel helpers, keeping the existing
# call sites (_set_satchel / _satchel_ids) unchanged.
_set_satchel = set_satchel
_satchel_ids = satchel_ids
@pytest.fixture
def clock() -> object:
@@ -54,6 +66,10 @@ def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
# The flat-id-list satchel helpers (_set_satchel / _satchel_ids) live in
# tests/conftest.py now, shared with the Wyrm suite; they are imported above.
def _strong_at_dungeon(game: Game, name: str) -> object:
"""Join *name*, make them unbeatable, and stand them in the dungeon menu."""
game.join(name)
@@ -65,6 +81,103 @@ def _strong_at_dungeon(game: Game, name: str) -> object:
return player
# ---------------------------------------------------------------------------
# A0) Forge ore — the combat-earned material that feeds the forge
# ---------------------------------------------------------------------------
def test_descend_win_drops_ore_into_satchel(tmp_path: Path, clock: object) -> None:
"""A won dungeon rung grants ore_dungeon_drop ore into the satchel."""
game = _game(tmp_path, clock)
player = _strong_at_dungeon(game, "Delver")
expect = game.world.settings.ore_dungeon_drop
ore_id = game.world.settings.forge_ore_item
out = game.action("Delver", "descend", "", "")
assert player.deepest_rung == 1 # the rung was cleared
assert game._satchel_find(player, ore_id) == (0, expect)
ore = game.world.item_by_id(ore_id)
assert ore is not None and ore.name in out # the find is narrated
def test_descend_win_ore_bumps_existing_stack(tmp_path: Path, clock: object) -> None:
"""A second cleared rung bumps the existing ore stack rather than opening a new one."""
game = _game(tmp_path, clock)
player = _strong_at_dungeon(game, "Delver")
drop = game.world.settings.ore_dungeon_drop
ore_id = game.world.settings.forge_ore_item
game.action("Delver", "descend", "", "")
game.action("Delver", "descend", "", "")
assert player.deepest_rung == 2
assert game._satchel_find(player, ore_id) == (0, 2 * drop) # one stack, doubled
assert game._satchel_distinct(player) == 1
def test_descend_win_ore_dropped_when_satchel_full(tmp_path: Path, clock: object) -> None:
"""When the bag has no ore stack AND is full of other kinds, ore is dropped.
This pins the ore-when-satchel-full edge: the grant goes through the same
add-chokepoint, so a full bag (distinct-stack cap reached, no ore stack to
bump) cannot take the ore — it is narrated as dropped, NEVER an error, and
the cleared rung still stands.
"""
game = _game(tmp_path, clock)
player = _strong_at_dungeon(game, "Delver")
cap = game.world.settings.satchel_max
ore_id = game.world.settings.forge_ore_item
# Fill the distinct-stack cap with NON-ore kinds, so an ore drop has no stack
# to bump and no free slot to open.
_set_satchel(game, player, ["minor_potion", "greater_potion", "elixir_of_the_vale"])
assert game._satchel_distinct(player) == cap
assert game._satchel_find(player, ore_id) is None
out = game.action("Delver", "descend", "", "")
assert player.deepest_rung == 1 # the rung still cleared — no error
assert game._satchel_find(player, ore_id) is None # the ore found no room
assert game._satchel_distinct(player) == cap # bag unchanged
assert "no room to pocket the ore" in out.lower()
def test_forest_win_drops_ore_on_a_successful_roll(
tmp_path: Path, clock: object, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A won forest fight grants one ore when the ore_forest_chance roll succeeds.
The roll is the injected RNG's ``chance``; forcing it True makes the wiring
deterministic (the chance itself is a tuning value, exercised by the sim).
"""
game = _game(tmp_path, clock)
game.join("Hunter")
player = game.players["Hunter"]
player.x, player.y = 35, 25 # forest_near
player.atk, player.def_, player.hp, player.max_hp = 200, 100, 500, 500
ore_id = game.world.settings.forge_ore_item
# Force the forest ore roll to succeed (chance(p) -> True for any p).
monkeypatch.setattr(game.rng, "chance", lambda _p: True)
game.action("Hunter", "fight", "", "")
assert game._satchel_find(player, ore_id) == (0, 1) # one ore from the win
def test_forest_loss_grants_no_ore(
tmp_path: Path, clock: object, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A LOST forest fight grants no ore even if the roll would succeed."""
game = _game(tmp_path, clock)
player = _doomed_fighter(game, "Doomed") # will lose (and has no potion)
ore_id = game.world.settings.forge_ore_item
monkeypatch.setattr(game.rng, "chance", lambda _p: True)
game.action("Doomed", "fight", "", "")
assert game._satchel_find(player, ore_id) is None # ore is a WIN-only reward
# ---------------------------------------------------------------------------
# A) The rung ladder
# ---------------------------------------------------------------------------
@@ -186,7 +299,7 @@ def test_descend_loss_with_potion_survives_keeping_depth(tmp_path: 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"])
_set_satchel(game, player, ["greater_potion"])
spawn = game.world.spawn
before_turns = player.turns_left
before_events = len(game.events)
@@ -198,7 +311,7 @@ def test_descend_loss_with_potion_survives_keeping_depth(tmp_path: Path, clock:
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 _satchel_ids(game, 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.
@@ -343,28 +456,44 @@ def test_buy_potion_stows_into_satchel(tmp_path: Path, clock: object) -> None:
out = game.action("Buyer", "buy", "", "minor_potion")
assert "satchel" in out.lower()
assert game._satchel_list(player) == ["minor_potion"]
assert _satchel_ids(game, 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."""
def test_satchel_potions_stack_into_one_slot(tmp_path: Path, clock: object) -> None:
"""Buying three of one potion makes ONE stack of qty 3, not three slots."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Buyer")
cap = game.world.settings.satchel_max
player.gold = 10000
for _ in range(cap):
for _ in range(3):
game.action("Buyer", "buy", "", "minor_potion")
assert len(game._satchel_list(player)) == cap
gold_at_cap = player.gold
stacks = game._satchel_stacks(player)
assert stacks == [("minor_potion", 3)] # one distinct slot, qty 3
assert game._satchel_distinct(player) == 1
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_satchel_distinct_cap_refuses_fourth_kind_but_tops_up_existing(
tmp_path: Path, clock: object
) -> None:
"""satchel_max caps DISTINCT stacks: three kinds fill the bag, a fourth kind
is refused, but topping up an existing stack still fits."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Buyer")
cap = game.world.settings.satchel_max # 3
player.gold = 10000
# Three DISTINCT kinds fill the distinct-stack cap (the shop sells exactly 3).
for item_id in ("minor_potion", "greater_potion", "elixir_of_the_vale"):
game.action("Buyer", "buy", "", item_id)
assert game._satchel_distinct(player) == cap
# A FOURTH distinct kind (ore, dropped straight in) is refused — bag full.
assert game._satchel_try_add(player, "iron_ore") is False
assert game._satchel_distinct(player) == cap
# But topping up an EXISTING stack still works (qty is unbounded).
assert game._satchel_try_add(player, "minor_potion") is True
assert game._satchel_find(player, "minor_potion") == (0, 2)
assert game._satchel_distinct(player) == cap # still three kinds
def test_quaff_drinks_strongest_and_caps_at_max_hp(tmp_path: Path, clock: object) -> None:
@@ -373,7 +502,7 @@ def test_quaff_drinks_strongest_and_caps_at_max_hp(tmp_path: Path, clock: object
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"])
_set_satchel(game, player, ["minor_potion", "greater_potion"])
player.max_hp = 100
player.hp = 90 # greater_potion heals 40, but the cap clamps the gain to 10
@@ -381,7 +510,25 @@ def test_quaff_drinks_strongest_and_caps_at_max_hp(tmp_path: Path, clock: object
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
assert _satchel_ids(game, player) == ["minor_potion"] # only the strong one left
def test_quaff_decrements_stack_and_removes_at_zero(tmp_path: Path, clock: object) -> None:
"""Quaffing a 2-deep potion stack decrements it; a second quaff empties it."""
game = _game(tmp_path, clock)
game.join("Drinker")
player = game.players["Drinker"]
_set_satchel(game, player, ["minor_potion", "minor_potion"]) # one stack, qty 2
player.max_hp = 100
player.hp = 10
game.action("Drinker", "quaff", "", "")
assert game._satchel_find(player, "minor_potion") == (0, 1) # decremented, not dropped
player.hp = 10
game.action("Drinker", "quaff", "", "")
assert game._satchel_find(player, "minor_potion") is None # the stack is gone at 0
assert game._satchel_stacks(player) == []
def test_quaff_empty_satchel_refuses(tmp_path: Path, clock: object) -> None:
@@ -389,7 +536,7 @@ def test_quaff_empty_satchel_refuses(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Drinker")
out = game.action("Drinker", "quaff", "", "")
assert "satchel is empty" in out.lower()
assert "no draught" in out.lower()
def test_quaff_at_full_hp_refuses_and_keeps_potion(tmp_path: Path, clock: object) -> None:
@@ -397,13 +544,13 @@ def test_quaff_at_full_hp_refuses_and_keeps_potion(tmp_path: Path, clock: object
game = _game(tmp_path, clock)
game.join("Drinker")
player = game.players["Drinker"]
game._satchel_set(player, ["minor_potion"])
_set_satchel(game, 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
assert _satchel_ids(game, player) == ["minor_potion"] # not wasted
def test_satchel_listed_in_status(tmp_path: Path, clock: object) -> None:
@@ -411,7 +558,7 @@ def test_satchel_listed_in_status(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
game.join("Drinker")
player = game.players["Drinker"]
game._satchel_set(player, ["minor_potion"])
_set_satchel(game, player, ["minor_potion"])
out = game.status("Drinker")
assert "satchel" in out.lower()
@@ -442,7 +589,7 @@ def test_death_save_fight_survives_at_potion_value(tmp_path: Path, clock: object
player = _doomed_fighter(game, "Doomed")
potion = game.world.item_by_id("greater_potion")
assert potion is not None
game._satchel_set(player, ["greater_potion"])
_set_satchel(game, player, ["greater_potion"])
spawn = game.world.spawn
before_events = len(game.events)
@@ -452,7 +599,7 @@ def test_death_save_fight_survives_at_potion_value(tmp_path: Path, clock: object
# 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 _satchel_ids(game, 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:]
@@ -473,7 +620,7 @@ def test_death_save_negative_without_satchel_check(
"""
game = _game(tmp_path, clock)
player = _doomed_fighter(game, "Doomed")
game._satchel_set(player, ["greater_potion"])
_set_satchel(game, player, ["greater_potion"])
spawn = game.world.spawn
monkeypatch.setattr(Game, "_death_save", lambda self, pl, lines: False)
@@ -481,7 +628,7 @@ def test_death_save_negative_without_satchel_check(
assert player.hp == 1 # bounced, not saved
assert (player.x, player.y) == spawn
assert game._satchel_list(player) == ["greater_potion"] # potion NOT spent
assert _satchel_ids(game, player) == ["greater_potion"] # potion NOT spent
assert "death's edge" not in out.lower() # no dramatic line
@@ -491,12 +638,26 @@ def test_death_save_uses_strongest_potion(tmp_path: Path, clock: object) -> None
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"])
_set_satchel(game, 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
assert _satchel_ids(game, player) == ["minor_potion"] # the weak one remains
def test_death_save_fires_from_a_stacked_potion(tmp_path: Path, clock: object) -> None:
"""The death-save works off a multi-deep potion stack, decrementing it by one."""
game = _game(tmp_path, clock)
player = _doomed_fighter(game, "Doomed")
potion = game.world.item_by_id("greater_potion")
assert potion is not None
_set_satchel(game, player, ["greater_potion", "greater_potion", "greater_potion"])
game.action("Doomed", "fight", "", "")
assert player.hp == min(player.max_hp, potion.heal) # saved at the potion's value
assert game._satchel_find(player, "greater_potion") == (0, 2) # one drunk, two left
def test_ambush_attacker_death_save(tmp_path: Path, clock: object) -> None:
@@ -519,14 +680,14 @@ def test_ambush_attacker_death_save(tmp_path: Path, clock: object) -> None:
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"])
_set_satchel(game, 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
assert _satchel_ids(game, attacker) == [] # potion spent
def test_ambush_victim_never_quaffs(tmp_path: Path, clock: object) -> None:
@@ -546,13 +707,13 @@ def test_ambush_victim_never_quaffs(tmp_path: Path, clock: object) -> None:
victim.atk, victim.def_, victim.hp = 1, 0, 3
victim.gold = 100
victim.turn_day = 0 # asleep
game._satchel_set(victim, ["greater_potion"])
_set_satchel(game, 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
assert _satchel_ids(game, victim) == ["greater_potion"] # the sleeper's potion is untouched
# ---------------------------------------------------------------------------
@@ -560,12 +721,19 @@ def test_ambush_victim_never_quaffs(tmp_path: Path, clock: object) -> None:
# ---------------------------------------------------------------------------
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)."""
def _ore_id(game: Game) -> str:
return game.world.settings.forge_ore_item
def test_forge_plus_one_raises_stat_and_costs_scaled_gold_and_ore(
tmp_path: Path, clock: object
) -> None:
"""forge +1 raises atk by 1 and costs base*(0+1) gold + 1 ore; +2 costs more of both."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
base = game.world.settings.forge_base_cost
player.gold = 10000
_set_satchel(game, player, [_ore_id(game)] * 20)
atk0 = player.atk
out1 = game.action("Smith", "forge", "weapon", "")
@@ -574,12 +742,14 @@ def test_forge_plus_one_raises_stat_and_costs_scaled_gold(tmp_path: Path, clock:
assert "+1" in out1
after_first = player.gold
assert after_first == 10000 - base * 1 # base * (0 + 1)
assert game._satchel_find(player, _ore_id(game)) == (0, 19) # 1 ore spent
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
assert game._satchel_find(player, _ore_id(game)) == (0, 17) # 2 more ore spent
def test_forge_armor_raises_def(tmp_path: Path, clock: object) -> None:
@@ -587,6 +757,7 @@ def test_forge_armor_raises_def(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 10000
_set_satchel(game, player, [_ore_id(game)] * 20)
def0 = player.def_
game.action("Smith", "forge", "armour", "")
@@ -600,10 +771,12 @@ def test_forge_caps_at_max_plus(tmp_path: Path, clock: object) -> None:
player = _at_shop(game, "Smith")
cap = game.world.settings.forge_max_plus
player.gold = 100000
_set_satchel(game, player, [_ore_id(game)] * 50)
for _ in range(cap):
game.action("Smith", "forge", "weapon", "")
assert player.weapon_plus == cap
atk_at_cap, gold_at_cap = player.atk, player.gold
ore_at_cap = game._satchel_find(player, _ore_id(game))
out = game.action("Smith", "forge", "weapon", "")
@@ -611,13 +784,15 @@ def test_forge_caps_at_max_plus(tmp_path: Path, clock: object) -> None:
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
assert game._satchel_find(player, _ore_id(game)) == ore_at_cap # no ore spent
def test_forge_unaffordable_refuses(tmp_path: Path, clock: object) -> None:
"""A hero who cannot pay the forge price is refused without mutation."""
def test_forge_unaffordable_gold_refuses(tmp_path: Path, clock: object) -> None:
"""A hero who cannot pay the forge gold is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 1 # far below the base cost
_set_satchel(game, player, [_ore_id(game)] * 20) # ore is fine; gold is not
atk0 = player.atk
out = game.action("Smith", "forge", "weapon", "")
@@ -625,9 +800,28 @@ def test_forge_unaffordable_refuses(tmp_path: Path, clock: object) -> None:
assert player.weapon_plus == 0
assert player.atk == atk0
assert player.gold == 1
assert game._satchel_find(player, _ore_id(game)) == (0, 20) # ore untouched
assert "gold" in out.lower()
def test_forge_without_ore_refuses_naming_both(tmp_path: Path, clock: object) -> None:
"""Plenty of gold but no ore: forge is refused, naming the gold AND ore cost."""
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 10000 # rich, but the satchel holds no ore
atk0 = player.atk
ore = game.world.item_by_id(_ore_id(game))
assert ore is not None
out = game.action("Smith", "forge", "weapon", "")
assert player.weapon_plus == 0 # no mutation
assert player.atk == atk0
assert player.gold == 10000
assert ore.name in out # the message names the ore requirement
assert "0 " + ore.name in out # and that the hero holds none
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)
@@ -652,6 +846,7 @@ def test_forge_then_buy_zeroes_plus_and_removes_phantom_atk(tmp_path: Path, cloc
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 100000
_set_satchel(game, player, [_ore_id(game)] * 20)
# 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")
@@ -686,6 +881,7 @@ def test_forge_then_sell_zeroes_plus_and_removes_phantom_atk(tmp_path: Path, clo
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 100000
_set_satchel(game, player, [_ore_id(game)] * 20)
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
@@ -708,6 +904,7 @@ def test_forge_plus_shown_in_status(tmp_path: Path, clock: object) -> None:
game = _game(tmp_path, clock)
player = _at_shop(game, "Smith")
player.gold = 100000
_set_satchel(game, player, [_ore_id(game)] * 20)
game.action("Smith", "buy", "", "iron_sword")
game.action("Smith", "forge", "weapon", "")
game.action("Smith", "forge", "weapon", "")
@@ -749,7 +946,7 @@ def test_rare_kill_heralds_and_drops_draught(tmp_path: Path, clock: object) -> N
# 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)
assert drop_id in _satchel_ids(game, 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")
@@ -765,7 +962,10 @@ def test_rare_kill_full_satchel_blocks_drop_but_kill_lands(tmp_path: Path, clock
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
# Fill the DISTINCT-stack cap with three different kinds (the rare drop is a
# fourth kind, greater_potion, with no stack to bump — so it has no room).
_set_satchel(game, player, ["minor_potion", "elixir_of_the_vale", _ore_id(game)])
assert game._satchel_distinct(player) == cap
before_events = len(game.events)
out = ""
@@ -779,7 +979,9 @@ def test_rare_kill_full_satchel_blocks_drop_but_kill_lands(tmp_path: Path, clock
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 game._satchel_distinct(player) == cap # still exactly full (distinct kinds)
# The rare drop (a fourth, distinct kind) never landed.
assert game._satchel_find(player, game.world.settings.rare_drop_item) is None
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
+1 -1
View File
@@ -6,4 +6,4 @@ import understone
def test_version_present() -> None:
assert understone.__version__ == "0.9.0"
assert understone.__version__ == "0.10.0"
+10 -4
View File
@@ -64,6 +64,7 @@ def test_player_round_trip_all_columns(tmp_path: Path) -> None:
post_day=739_400,
gambles=2,
gamble_day=739_400,
banked=420,
)
store.upsert_player(player)
store.commit()
@@ -73,6 +74,7 @@ def test_player_round_trip_all_columns(tmp_path: Path) -> None:
players, _ = reopened.load_all()
loaded = players["Brandr"]
assert loaded == player
assert loaded.banked == 420
# Spot-check the fields most prone to silent drop.
assert loaded.def_ == 4
assert loaded.turn_day == 739_400
@@ -207,15 +209,17 @@ def test_meta_round_trip(tmp_path: Path) -> 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."""
def test_retention_columns_round_trip(tmp_path: Path) -> None:
"""The retention columns survive a reopen: depth, the v0.10 stack-encoded
satchel, the two forged plusses, and the v0.10 banked vault gold."""
store = _store(tmp_path)
player = make_player(
name="Delver",
deepest_rung=2,
satchel="minor_potion,greater_potion",
satchel="minor_potion:3,iron_ore:5", # v0.10 "id:qty" stack encoding
weapon_plus=2,
armor_plus=1,
banked=300,
)
store.upsert_player(player)
store.commit()
@@ -226,9 +230,10 @@ def test_v0_7_depth_columns_round_trip(tmp_path: Path) -> None:
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.satchel == "minor_potion:3,iron_ore:5"
assert loaded.weapon_plus == 2
assert loaded.armor_plus == 1
assert loaded.banked == 300
reopened.close()
@@ -259,5 +264,6 @@ def test_v0_7_depth_columns_default_for_legacy_rows(tmp_path: Path) -> None:
assert old.satchel == ""
assert old.weapon_plus == 0
assert old.armor_plus == 0
assert old.banked == 0 # the v0.10 vault column defaults too
assert reopened.get_meta("schema_version") == "1" # stamp unchanged
reopened.close()
+63
View File
@@ -0,0 +1,63 @@
"""The satchel "id:qty" wire codec (understone.engine.satchel).
Pins the single-source codec the game façade, the Watch payload, and the
balance simulator all decode through. The format is comma-joined ``id:qty``
stacks; this proves a clean round-trip, the defensive bare-id => qty-1 rule, the
malformed/zero/empty fragments that are skipped, and that the encoder never
emits a zero-or-negative stack.
"""
from __future__ import annotations
import pytest
from understone.engine.satchel import decode_satchel, encode_satchel
def test_round_trips_id_qty_stacks() -> None:
"""The canonical "id:qty,id:qty" data decodes and re-encodes unchanged."""
encoded = "minor_potion:3,iron_ore:5"
stacks = decode_satchel(encoded)
assert stacks == [("minor_potion", 3), ("iron_ore", 5)]
assert encode_satchel(stacks) == encoded
def test_bare_id_decodes_as_qty_one() -> None:
"""A colonless chunk is a single item (defensive — never silently dropped)."""
assert decode_satchel("minor_potion") == [("minor_potion", 1)]
# Mixed with a normal stack, order preserved.
assert decode_satchel("minor_potion,iron_ore:5") == [
("minor_potion", 1),
("iron_ore", 5),
]
@pytest.mark.parametrize(
("encoded", "reason"),
[
("id:0", "zero quantity"),
("id:-1", "negative quantity"),
("id:abc", "non-integer quantity"),
(":5", "empty id"),
("", "empty string"),
("minor_potion:3,", "trailing comma yields an empty chunk"),
(",minor_potion:3", "leading comma yields an empty chunk"),
],
)
def test_skips_malformed_or_zero_fragments(encoded: str, reason: str) -> None:
"""A present-but-invalid or non-positive fragment is skipped; valid ones survive."""
stacks = decode_satchel(encoded)
assert all(item_id and qty > 0 for item_id, qty in stacks), reason
# The only valid stack in the trailing/leading-comma cases is the potion.
if "minor_potion:3" in encoded:
assert stacks == [("minor_potion", 3)]
else:
assert stacks == []
def test_encode_drops_non_positive_stacks() -> None:
"""The encoder never emits "id:0" or a negative quantity."""
assert encode_satchel([("minor_potion", 0)]) == ""
assert encode_satchel([("minor_potion", -2)]) == ""
assert encode_satchel([("minor_potion", 2), ("iron_ore", 0)]) == "minor_potion:2"
assert encode_satchel([]) == ""
+121
View File
@@ -310,6 +310,28 @@ def test_ambush_win_transfers_gold_and_bounces_victim(tmp_path: Path, clock: obj
assert game.store.has_ambushed("Raider", "Sleeper", _TODAY) is True
def test_ambush_steals_only_carried_gold_not_the_vault(tmp_path: Path, clock: object) -> None:
"""A winning ambush robs carried gold only — banked vault gold is untouched.
The steal is a slice of ``target.gold`` (gold in hand); the strongbox
(``banked``) is safe by design. This pins the vault's whole point: bank your
coin before you sleep and a sleeping-robber cannot lift it.
"""
game = _game(tmp_path, clock)
attacker, target = _arm_ambush(game, target_gold=40)
target.banked = 1000 # a fat vault the raider must not be able to touch
attacker.atk = 200 # one-shot the sleeper
target.hp = 5
pct = game.world.settings.ambush_gold_pct
steal = 40 * pct // 100 # a slice of the CARRIED 40, not the banked 1000
game.action("Raider", "ambush", "Sleeper", "")
assert target.gold == 40 - steal # carried gold robbed
assert target.banked == 1000 # the vault is wholly untouched
assert attacker.gold == game.world.settings.starting_gold + steal
def test_ambush_win_applies_attacker_wear(tmp_path: Path, clock: object) -> None:
"""A multi-round win banks the attacker's wear: the log narrates the
sleeper's counter-blows, so the sheet must show the HP they cost.
@@ -739,3 +761,102 @@ def test_gamble_small_win_is_quiet(tmp_path: Path, clock: object) -> None:
new = game.events[events_before:]
assert all(e.kind != "gamble" for e in new)
assert player.gold == 1010
# ---------------------------------------------------------------------------
# The Vault — deposit/withdraw at the inn (no turn; banked gold is safe)
# ---------------------------------------------------------------------------
def test_deposit_moves_gold_to_the_vault_no_turn(tmp_path: Path, clock: object) -> None:
"""Deposit moves coin from hand to vault, costs no turn, and is friendly."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 100
turns_before = player.turns_left
out = game.action("Saver", "deposit", "", "", "", 60)
assert player.gold == 40
assert player.banked == 60
assert player.turns_left == turns_before # banking spends no turn
assert "strongbox" in out.lower()
def test_withdraw_moves_gold_back_to_hand(tmp_path: Path, clock: object) -> None:
"""Withdraw moves coin from vault to hand."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 10
player.banked = 90
game.action("Saver", "withdraw", "", "", "", 50)
assert player.gold == 60
assert player.banked == 40
def test_deposit_amount_exceeding_holdings_refused(tmp_path: Path, clock: object) -> None:
"""Depositing more than you carry is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 30
player.banked = 0
out = game.action("Saver", "deposit", "", "", "", 50)
assert player.gold == 30 # unchanged
assert player.banked == 0
assert "1 to 30" in out
def test_deposit_with_nothing_in_hand_refused(tmp_path: Path, clock: object) -> None:
"""Depositing with an empty hand is a friendly refusal."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 0
out = game.action("Saver", "deposit", "", "", "", 10)
assert player.banked == 0
assert "no coin" in out.lower()
def test_withdraw_amount_exceeding_vault_refused(tmp_path: Path, clock: object) -> None:
"""Withdrawing more than is banked is refused without mutation."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.gold = 0
player.banked = 20
out = game.action("Saver", "withdraw", "", "", "", 50)
assert player.gold == 0
assert player.banked == 20 # unchanged
assert "1 to 20" in out
def test_withdraw_empty_vault_refused(tmp_path: Path, clock: object) -> None:
"""Withdrawing from an empty vault is a friendly refusal."""
game = _game(tmp_path, clock)
player = _at_inn(game, "Saver")
player.banked = 0
out = game.action("Saver", "withdraw", "", "", "", 10)
assert player.gold == game.world.settings.starting_gold # unchanged
assert "empty" in out.lower()
def test_status_shows_carried_and_vault_gold(tmp_path: Path, clock: object) -> None:
"""door_status reports gold as carried-on-hand plus banked-in-the-vault."""
game = _game(tmp_path, clock)
game.join("Saver")
player = game.players["Saver"]
player.gold = 75
player.banked = 250
out = game.status("Saver")
assert "75 on hand" in out
assert "250 in the vault" in out
+54
View File
@@ -264,6 +264,47 @@ def test_state_payload_includes_joined_player(tmp_path: Path, clock: object) ->
assert brandr["hp"] == brandr["max_hp"]
assert brandr["mode"] == "tile"
assert (brandr["x"], brandr["y"]) == game.world.spawn
# v0.10: a fresh hero shows their starting gold on hand, nothing banked, and
# an empty satchel.
assert brandr["gold"] == game.world.settings.starting_gold
assert brandr["banked"] == 0
assert brandr["satchel"] == []
def test_state_payload_surfaces_gold_banked_and_satchel(tmp_path: Path, clock: object) -> None:
"""A joined hero with a stocked satchel and banked gold shows the right values.
The lobby TV surfaces the whole shared world, so each player's purse (gold
on hand + vault) and satchel stacks (name + qty, resolved via the pack) ride
the state payload.
"""
game = _game(tmp_path, clock)
game.join("Brandr")
player = game.players["Brandr"]
player.gold = 120
player.banked = 300
game._satchel_set_stacks(player, [("iron_ore", 5), ("minor_potion", 2)])
payload = watch.build_state_payload(game)
brandr = next(p for p in payload["players"] if p["name"] == "Brandr") # type: ignore[union-attr]
assert brandr["gold"] == 120
assert brandr["banked"] == 300
# Stacks resolve their display name from the pack, preserving stow order.
assert brandr["satchel"] == [
{"name": "Iron Ore", "qty": 5},
{"name": "Minor Potion", "qty": 2},
]
def test_state_payload_satchel_unknown_id_falls_back_to_raw(tmp_path: Path, clock: object) -> None:
"""A satchel id no longer in the pack falls back to the raw id, never blank."""
game = _game(tmp_path, clock)
game.join("Brandr")
game.players["Brandr"].satchel = "ghost_item:2" # not in the pack
payload = watch.build_state_payload(game)
brandr = next(p for p in payload["players"] if p["name"] == "Brandr") # type: ignore[union-attr]
assert brandr["satchel"] == [{"name": "ghost_item", "qty": 2}]
def test_state_payload_reports_all_players_including_menu(tmp_path: Path, clock: object) -> None:
@@ -473,6 +514,19 @@ def test_watch_html_uses_other_player_marker() -> None:
assert "\\u263b" in watch.WATCH_HTML
def test_watch_html_renders_gold_banked_and_satchel() -> None:
"""The Adventurers panel JS references each player's gold, vault, and satchel."""
html = watch.WATCH_HTML
# The roster sub-lines read these state fields by name.
assert "p.gold" in html
assert "p.banked" in html
assert "p.satchel" in html
# The satchel line has a dedicated renderer with an empty-bag note.
assert "satchelText" in html
assert "satchel empty" in html
assert "vault" in html
def test_watch_html_has_day_phase_machinery() -> None:
"""The dusk/dawn glow-up is wired: the tint classes and the UTC-hour read."""
html = watch.WATCH_HTML
@@ -621,6 +621,75 @@ def test_forge_base_cost_out_of_band_rejected(tmp_path: Path) -> None:
load_world(pack)
def test_forge_ore_item_unknown_rejected(tmp_path: Path) -> None:
"""A forge_ore_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"]["forge_ore_item"] = "no_such_ore"
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match="forge_ore_item = 'no_such_ore' is not a known"):
load_world(pack)
def test_forge_ore_item_non_material_rejected(tmp_path: Path) -> None:
"""A forge_ore_item that names a non-material (a potion) is rejected.
Ore is carried in the satchel and spent at the forge, never equipped or
quaffed, so a consumable/weapon/armour id is incoherent — the loader pins
the slot to ``material`` (mirroring the rare_drop_item consumable check).
"""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["forge_ore_item"] = "greater_potion" # a draught, not ore
_rewrite(pack / "world.json", mutate)
with pytest.raises(
WorldLoadError, match="forge_ore_item = 'greater_potion' must be a material"
):
load_world(pack)
def test_forge_ore_per_plus_out_of_band_rejected(tmp_path: Path) -> None:
"""forge_ore_per_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_ore_per_plus"] = 11
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"forge_ore_per_plus = 11 is out of band \(0\.\.10\)"):
load_world(pack)
def test_ore_dungeon_drop_out_of_band_rejected(tmp_path: Path) -> None:
"""ore_dungeon_drop above its 0..20 band is a load error."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ore_dungeon_drop"] = 21
_rewrite(pack / "world.json", mutate)
with pytest.raises(WorldLoadError, match=r"ore_dungeon_drop = 21 is out of band \(0\.\.20\)"):
load_world(pack)
def test_ore_forest_chance_out_of_band_rejected(tmp_path: Path) -> None:
"""ore_forest_chance outside 0.0..1.0 is a load error (it is a probability)."""
pack = _clone_pack(tmp_path)
def mutate(data: dict[str, Any]) -> None:
data["settings"]["ore_forest_chance"] = 1.5
_rewrite(pack / "world.json", mutate)
with pytest.raises(
WorldLoadError, match=r"ore_forest_chance = 1.5 is out of band \(0.0..1.0\)"
):
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)
@@ -648,6 +717,13 @@ def test_shipped_pack_carries_rares_and_weights() -> None:
assert world.settings.forge_base_cost == 60
assert world.settings.forge_max_plus == 3
assert world.settings.dungeon_tiers == (3, 4, 5)
# v0.10 ore-forge settings resolve, and the forge ore is a material item.
assert world.settings.forge_ore_item == "iron_ore"
ore = world.item_by_id(world.settings.forge_ore_item)
assert ore is not None and ore.slot.value == "material"
assert world.settings.forge_ore_per_plus == 1
assert world.settings.ore_dungeon_drop == 2
assert world.settings.ore_forest_chance == 0.2
def test_monster_weight_and_rare_default_when_omitted(tmp_path: Path) -> None:
+55 -5
View File
@@ -20,7 +20,12 @@ from pathlib import Path
import pytest
from tests.conftest import fixed_clock, utc
from tests.conftest import (
fixed_clock,
satchel_ids,
set_satchel,
utc,
)
from understone.engine.models import Mode
from understone.engine.rng import GameRNG
from understone.game import Game
@@ -29,6 +34,11 @@ from understone.world.loader import load_world
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
# Module-local aliases for the shared satchel helpers, keeping the existing
# call sites (_set_satchel / _satchel_ids) unchanged.
_set_satchel = set_satchel
_satchel_ids = satchel_ids
@pytest.fixture
def clock() -> object:
@@ -41,6 +51,10 @@ def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
# The flat-id-list satchel helpers (_set_satchel / _satchel_ids) live in
# tests/conftest.py now, shared with the descend suite; they are imported above.
def _at_dungeon(game: Game, name: str) -> object:
"""Place an already-joined player inside the dungeon menu, at the deep floor.
@@ -217,6 +231,42 @@ def test_challenge_win_resets_with_legacy(tmp_path: Path, clock: object) -> None
assert player.bestow_spent == 7
def test_challenge_win_legacy_reset_spares_the_vault(tmp_path: Path, clock: object) -> None:
"""The vault SURVIVES a Wyrm-win rebirth; carried gold resets to starting.
Banked gold is the one wealth (besides the ★) a legacy reset does not clear:
the strongbox is the inn's, not the reborn hero's. This deposits gold into
the vault through the inn, drives a Wyrm WIN, and asserts ``banked`` is
UNCHANGED while ``gold`` drops back to ``starting_gold``.
Negative-check (the revert-and-observe-failure discipline of this module):
the implementer temporarily added ``player.banked = 0`` to
Game._reset_with_legacy; this test then FAILED on the unchanged-``banked``
assertion (the vault was wiped by the rebirth). The line was restored, so
this test is the standing regression that the vault outlives the reset.
"""
game = _game(tmp_path, clock)
game.join("Brak")
player = game.players["Brak"]
# Bank some gold through the real inn path, then stand at the dungeon floor.
player.gold = 200
player.mode = Mode.MENU
player.at_location = "inn"
game.action("Brak", "deposit", "", "", amount=120)
assert player.banked == 120 and player.gold == 80 # vault holds; hand drained
player = _at_dungeon(game, "Brak")
player.level = game.world.settings.wyrm_min_level
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
out = game.action("Brak", "challenge", "", "")
assert "freed the vale" in out.lower() # a genuine win drove the reset
assert player.wins == 1
assert player.banked == 120 # the vault is untouched by the rebirth
assert player.gold == game.world.settings.starting_gold # carried wealth resets
def test_challenge_win_star_in_rank_and_hall(tmp_path: Path, clock: object) -> None:
"""After a win, door_rank shows the ★ and renders the Hall of Legends."""
game = _game(tmp_path, clock)
@@ -304,7 +354,7 @@ def test_challenge_loss_with_potion_survives_no_legacy_reset(tmp_path: Path, clo
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"])
_set_satchel(game, player, ["greater_potion"])
floor = len(game.world.settings.dungeon_tiers)
spawn = game.world.spawn
before_turns = player.turns_left
@@ -317,7 +367,7 @@ def test_challenge_loss_with_potion_survives_no_legacy_reset(tmp_path: Path, clo
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 _satchel_ids(game, 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.
@@ -349,7 +399,7 @@ def test_challenge_loss_potion_negative_without_save_devours(
game = _game(tmp_path, clock)
game.join("Brak")
player = _doomed_wyrm_challenger(game, "Brak")
game._satchel_set(player, ["greater_potion"])
_set_satchel(game, player, ["greater_potion"])
floor = len(game.world.settings.dungeon_tiers)
spawn = game.world.spawn
@@ -360,7 +410,7 @@ def test_challenge_loss_potion_negative_without_save_devours(
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 _satchel_ids(game, 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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Understone — a BBS-style ANSI door game served over MCP."""
__version__ = "0.9.0"
__version__ = "0.10.0"
+94 -11
View File
@@ -269,9 +269,69 @@ def _render_bands() -> str:
f"error naming the legal set."
)
parts.append("\n### The ore-gated forge (`world.json` → `settings`)\n")
ore_per = loader.SETTINGS_BANDS["forge_ore_per_plus"]
dungeon = loader.SETTINGS_BANDS["ore_dungeon_drop"]
parts.append(
"Forging a +1 edge now costs both GOLD and ORE — a `material` item the "
"hero earns in combat, never buys. Four settings bind it:"
)
parts.append(
"* `forge_ore_item` — REQUIRED. The item id of your world's forge ore; "
"it must name an `items.json` entry whose `slot` is `material` (an "
"unknown id or a non-material slot is a load error). The Vale uses "
"`iron_ore`."
)
parts.append(
f"* `forge_ore_per_plus` — band `{ore_per[0]}..{ore_per[1]}`. Ore per +1 "
f"step: a +N forge costs `(current_plus + 1) * forge_ore_per_plus` ore. "
f"{_forge_ore_worked_example()}"
)
parts.append(
f"* `ore_dungeon_drop` — band `{dungeon[0]}..{dungeon[1]}`. Ore granted "
f"on every WON dungeon rung — the reliable source. The Vale drops 2."
)
parts.append(
"* `ore_forest_chance` — a `0.0`..`1.0` probability (a float, validated "
"outside the integer band table). The chance a WON forest fight yields "
"one ore — the occasional bonus source. The Vale uses `0.2`."
)
parts.append(
"\nOre rides the satchel as a stack, so it shares the `satchel_max` "
"DISTINCT-stack budget with potions (per-stack quantity is unbounded). "
"Tune the two sources so a hero who descends steadily earns enough ore "
"to forge without grinding — the `simulate` bot will tell you if the "
"gate stalls a winnable run."
)
return "\n".join(parts)
def _forge_ore_worked_example() -> str:
"""Render the per-step ore costs from the bundled Vale's live forge settings.
The starter template :func:`cli_newpack` copies IS the bundled Vale, so the
worked figures are computed from its actual ``forge_ore_per_plus`` and
``forge_max_plus`` rather than hardcoded — a retune of the template moves
the manual with it. The steps are ``per_plus * (i + 1)`` for each ``i`` in
``range(forge_max_plus)``; the total is what it costs to max one slot.
"""
settings = loader.load_world(PACKAGED_WORLD_DIR).settings
per_plus = settings.forge_ore_per_plus
max_plus = settings.forge_max_plus
steps = [per_plus * (i + 1) for i in range(max_plus)]
if not steps:
return (
f"At the template's value of {per_plus}, slots cannot be forged (`forge_max_plus` 0)."
)
ladder = ", ".join(str(cost) for cost in steps)
total = sum(steps)
return (
f"At the template's value of {per_plus}, the steps cost {ladder} ore "
f"({total} ore to max a slot at `forge_max_plus` {max_plus})."
)
def _reserved_glyph_list() -> list[str]:
"""Return the reserved glyphs in a stable, readable order for the manual."""
box = [g for g in "┌┐└┘─│═" if g in loader.RESERVED_GLYPHS]
@@ -341,8 +401,9 @@ def _render_validate_coverage() -> str:
"with weights `> 0`, `min <= max`, and amounts in their per-kind band.",
"* **Cross-references** — `legend` → terrain key, location placements → "
"`locations.json` keys, `starting_weapon`/`starting_armor` → item ids, "
'`boss_monster` → a monster flagged `"boss": true`, and '
"`rare_drop_item` → a consumable item id.",
'`boss_monster` → a monster flagged `"boss": true`, '
"`rare_drop_item` → a consumable item id, and `forge_ore_item` → a "
"`material` item id.",
"* **Zone tiers** — every zone's tier band must overlap at least one monster tier.",
"* **Dungeon ladder** — every `dungeon_tiers` tier must have a non-boss "
"monster, and that tier's FIRST monster (its fixed rung guardian) must "
@@ -398,7 +459,8 @@ The cross-references the loader enforces:
* `settings.starting_weapon` / `starting_armor` must be ids from
`items.json`; `settings.boss_monster` must be an id from `monsters.json`
that is flagged `"boss": true`; `settings.rare_drop_item` must be an id from
`items.json` whose `slot` is `consumable`;
`items.json` whose `slot` is `consumable`; `settings.forge_ore_item` must be
an id from `items.json` whose `slot` is `material`;
* every tier in `settings.dungeon_tiers` must be backed by a NON-boss monster;
* every zone's tier band must overlap at least one monster tier.
@@ -462,13 +524,24 @@ first entry of a tier that backs a `dungeon_tiers` rung.
### `items.json`
A list of equipment and consumables. `slot` is `weapon`, `armor`, or
`consumable`. Weapons add `atk`, armour adds `def`, consumables `heal`.
A list of equipment, consumables, and crafting materials. `slot` is `weapon`,
`armor`, `consumable`, or `material`. Weapons add `atk`, armour adds `def`,
consumables `heal`; a `material` carries none of these — it is the forge ORE,
carried in the satchel and spent at the forge.
```json
{"id": "short_sword", "name": "Short Sword", "slot": "weapon", "atk": 5, "price": 40}
```
The forge ore is a `material` item the player EARNS in combat (not the shop):
price it `0` — ore is never bought or sold — and point `settings.forge_ore_item`
at its id. A won dungeon rung always drops `settings.ore_dungeon_drop` of it, and
a won forest fight has a `settings.ore_forest_chance` chance of one.
```json
{"id": "iron_ore", "name": "Iron Ore", "slot": "material", "price": 0}
```
### `locations.json`
An object keyed by location key. Each entry is a building kind with a menu of
@@ -487,11 +560,17 @@ the verbs the engine honours inside each are:
| `kind` | actions the engine understands |
| --- | --- |
| `inn` | `rest`, `gamble`, `leave` |
| `inn` | `rest`, `deposit`, `withdraw`, `gamble`, `leave` |
| `shop` | `buy`, `sell`, `forge`, `leave` |
| `healer` | `heal`, `leave` |
| `dungeon` | `descend`, `challenge`, `leave` |
The inn's `deposit`/`withdraw` are the VAULT: a player banks gold into the inn
strongbox (`deposit amount=<gold>`) and draws it back (`withdraw amount=<gold>`).
Banked gold is SAFE from ambush — a sleeping-robber only ever lifts gold in hand
— and it SURVIVES the Wyrm-win legacy reset, so it is the one store of wealth
that carries across runs. Both cost no turn.
`quaff` (drink a satchel tonic) is legal **anywhere** and needs no menu entry.
The `actions` list is advisory — it is the menu the narrator offers, NOT a
validated whitelist (see "What `validate` checks" below): a verb the engine does
@@ -598,11 +677,15 @@ 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.
the floor as well as on level. Size the satchel with `satchel_max` — it caps the
DISTINCT stacks the bag holds (potions and ore each take a slot; per-stack
quantity is unbounded), and it is the death-save reserve, so keep it small (the
Vale carries 3). The forge is the late-game GOLD-AND-ORE sink: `forge_base_cost`
is the gold price of a +1 edge and scales up each tier (`base * (current_plus +
1)`), capped at `forge_max_plus`, and each step ALSO costs ore (see the ore-gated
forge above). Ore is won in the deep (and seldom in the forest), so the forge is
fed by descending — price the gold so a fully-forged piece is a multi-day saving,
and set the ore sources so a steady delver can afford it without a grind.
**Rare beasts.** A rare monster is a small legend: give it a low `weight` so it
surfaces seldom, stats and rewards a clear notch above its tier, and remember it
+24 -2
View File
@@ -24,6 +24,10 @@ class Slot(StrEnum):
WEAPON = "weapon"
ARMOR = "armor"
CONSUMABLE = "consumable"
# v0.10 forge ore: a crafting MATERIAL carried in the satchel and spent at
# the forge. It is never equipped, never quaffed (no atk/def/heal), and
# never sold or bought — ore is earned in combat, not traded.
MATERIAL = "material"
@dataclass(slots=True)
@@ -63,12 +67,21 @@ class Player:
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.
# carried satchel (see below), and the enhancement plus on whichever
# weapon/armour is CURRENTLY equipped in each slot.
deepest_rung: int = 0
# v0.10 STACK-BASED satchel: comma-joined "id:qty" stacks ('' = empty),
# e.g. "minor_potion:3,iron_ore:5". ``satchel_max`` caps DISTINCT stacks,
# not total items; per-stack qty is unbounded. Replaces the v0.7 flat id
# list. The "id:qty" wire format is owned by understone.engine.satchel
# (decode_satchel/encode_satchel); every reader goes through that codec.
satchel: str = ""
weapon_plus: int = 0
armor_plus: int = 0
# v0.10 the Vault: gold banked at the inn. SAFE from ambush (the steal only
# ever touches carried ``gold``) and SURVIVES the Wyrm-win legacy reset (a
# small persistent reward across runs, like a win ★).
banked: int = 0
@dataclass(frozen=True, slots=True)
@@ -204,6 +217,15 @@ class Settings:
forge_base_cost: int
forge_max_plus: int
rare_drop_item: str
# v0.10 the ore-gated forge: the world's forge MATERIAL item id (validated
# to slot=material), the ore each +1 step costs (need = (plus + 1) *
# per_plus), and the two ore sources — a guaranteed drop on a won dungeon
# rung and a chance of one ore on a won forest fight. Ore is combat-earned,
# never purchasable; the forge spends gold AND ore.
forge_ore_item: str
forge_ore_per_plus: int
ore_dungeon_drop: int
ore_forest_chance: float
# v0.8 "worlds without authors": the Watch's per-world CRT palette. One of
# the names in WATCH_THEMES; defaults to "phosphor" (the original green), so
# a pack that omits it looks exactly as the Vale always has.
@@ -0,0 +1,62 @@
"""The satchel wire codec — the one home for the ``"id:qty"`` stack encoding.
A player's satchel is stored as a single string: comma-joined ``id:qty`` stacks,
e.g. ``"minor_potion:3,iron_ore:5"``; an empty string is an empty bag. This
module is the SINGLE source of truth for that format. Three readers carried a
byte-identical decode loop (the game façade, the Watch payload builder, and the
balance simulator); they all delegate here so the format is described — and
parsed — in exactly one place.
The codec is pure and stdlib-only: it knows the wire shape and nothing else.
It does NOT collapse duplicate ids into one stack, resolve ids against a content
pack, or enforce the distinct-stack cap — those are stack *semantics* the
callers own. The codec only encodes and decodes.
"""
from __future__ import annotations
def decode_satchel(s: str) -> list[tuple[str, int]]:
"""Decode the ``"id:qty"`` satchel string into ordered ``(item_id, qty)`` stacks.
Splits on ``","`` and skips empty chunks (so an empty string, a leading or
trailing comma, and a doubled comma all yield no spurious stack). Each chunk
is partitioned on ``":"``:
* a chunk with no colon (a bare id) parses as quantity ``1`` — a colonless
fragment is treated as a single item, never silently dropped;
* a chunk whose quantity is present but not an integer, or is ``<= 0``, is
skipped;
* a chunk with an empty id is skipped.
Order is preserved (first-stowed first), which fixes which potion a heal tie
resolves to. The codec collapses nothing — callers own stack semantics.
"""
stacks: list[tuple[str, int]] = []
for chunk in s.split(","):
if not chunk:
continue
item_id, sep, qty_str = chunk.partition(":")
if not item_id:
continue
if not sep:
# A bare id with no colon is a single item (defensive: never drop it).
stacks.append((item_id, 1))
continue
try:
qty = int(qty_str)
except ValueError:
continue
if qty > 0:
stacks.append((item_id, qty))
return stacks
def encode_satchel(stacks: list[tuple[str, int]]) -> str:
"""Encode ``(item_id, qty)`` stacks back into the comma-joined ``"id:qty"`` string.
Any stack at quantity ``<= 0`` is dropped, so the encoding never emits
``"id:0"``; this is the single home for the drop-at-empty rule, letting
callers decrement freely and rely on a spent-to-zero stack falling away.
"""
return ",".join(f"{item_id}:{qty}" for item_id, qty in stacks if qty > 0)
+199 -57
View File
@@ -20,6 +20,7 @@ from understone.engine.log import Event, since_visible
from understone.engine.models import Mode, Monster, Player, Slot
from understone.engine.rank import HallEntry, RankEntry, leaderboard
from understone.engine.rng import GameRNG
from understone.engine.satchel import decode_satchel, encode_satchel
from understone.engine.textwidth import is_grid_safe
from understone.persistence import EVENT_TAIL_KEEP
from understone.screen.grid import Cell, CellGrid
@@ -206,45 +207,70 @@ class Game:
return None
return cleaned
# -- the satchel: a tiny carried-potion bag (v0.7) -------------------
# -- the satchel: a stack-based carried bag (v0.7 potions, v0.10 stacks) ---
@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]
def _satchel_stacks(player: Player) -> list[tuple[str, int]]:
"""Return the carried satchel as ordered ``(item_id, qty)`` stacks.
The game-side façade over :func:`~understone.engine.satchel.decode_satchel`:
the codec owns the ``"id:qty"`` wire shape ('' stored = empty bag, order
preserved, malformed/zero-qty fragments skipped); the rest of the façade
calls this helper by its established name.
"""
return decode_satchel(player.satchel)
@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 _satchel_set_stacks(player: Player, stacks: list[tuple[str, int]]) -> None:
"""Store *stacks* back as the comma-joined ``id:qty`` satchel.
def _strongest_potion(self, ids: list[str]) -> tuple[int, Item] | None:
"""Return the (index, item) of the highest-heal potion in *ids*, or None.
Delegates to :func:`~understone.engine.satchel.encode_satchel`, which
drops any qty <= 0 (the single home for the drop-at-empty rule), so
callers may decrement freely and let the empty stack fall away.
"""
player.satchel = encode_satchel(stacks)
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``.
def _satchel_distinct(self, player: Player) -> int:
"""Return how many DISTINCT item stacks the satchel currently holds."""
return len(self._satchel_stacks(player))
def _satchel_find(self, player: Player, item_id: str) -> tuple[int, int] | None:
"""Return the ``(stack index, qty)`` of *item_id*, or ``None`` if absent."""
for index, (existing_id, qty) in enumerate(self._satchel_stacks(player)):
if existing_id == item_id:
return index, qty
return None
def _strongest_potion(self, stacks: list[tuple[str, int]]) -> tuple[int, Item] | None:
"""Return the (stack index, item) of the highest-heal POTION, or None.
Resolves each stack's id against the pack and picks the consumable with
the greatest ``heal``; ties keep the earliest stack. Non-consumable
stacks (ore and other materials) and unknown ids are ignored, so a bag
of nothing but ore — or one edited out from under a save — yields
``None``. The index returned is the stack's position, for decrementing.
"""
best: tuple[int, Item] | None = None
for index, item_id in enumerate(ids):
for index, (item_id, _qty) in enumerate(stacks):
item = self.world.item_by_id(item_id)
if item is None:
if item is None or item.slot is not Slot.CONSUMABLE:
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:
"""Render the satchel as a status line: ``Name ×qty`` stacks or empty."""
stacks = self._satchel_stacks(player)
if not stacks:
return "Satchel: empty"
names = []
for item_id in ids:
parts = []
for item_id, qty in stacks:
item = self.world.item_by_id(item_id)
names.append(item.name if item is not None else item_id)
name = item.name if item is not None else item_id
parts.append(f"{name} ×{qty}")
cap = self.world.settings.satchel_max
return f"Satchel ({len(ids)}/{cap}): " + ", ".join(names)
return f"Satchel ({len(stacks)}/{cap}): " + ", ".join(parts)
def _footer(self, player: Player) -> str:
nxt = leveling.xp_for_level(player.level + 1, self.world.settings)
@@ -505,7 +531,8 @@ class Game:
f"HP {player.hp}/{player.max_hp} ATK {player.atk} DEF {player.def_}",
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"Gold: {player.gold} on hand, {player.banked} in the vault",
f"Turns {player.turns_left}/{self.world.settings.daily_turns}",
f"Deep: rung {player.deepest_rung}/{rungs}",
self._satchel_blurb(player),
]
@@ -716,31 +743,50 @@ class Game:
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)
stacks = self._satchel_stacks(player)
best = self._strongest_potion(stacks)
if best is None:
return False
index, potion = best
del ids[index]
self._satchel_set(player, ids)
self._satchel_spend(player, index)
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.
def _satchel_try_add(self, player: Player, item_id: str, qty: int = 1) -> bool:
"""Add *qty* of *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.
Stack-aware (v0.10): if a stack of *item_id* already exists its qty is
bumped (this ALWAYS fits — per-stack qty is unbounded). Otherwise a new
stack is appended only while the DISTINCT-stack count is below
``satchel_max``; a full bag refuses without mutation and returns
``False``. The single chokepoint for adding to the satchel, so the
distinct-stack cap lives in exactly one place. *qty* must be >= 1.
"""
ids = self._satchel_list(player)
if len(ids) >= self.world.settings.satchel_max:
stacks = self._satchel_stacks(player)
for index, (existing_id, existing_qty) in enumerate(stacks):
if existing_id == item_id:
stacks[index] = (existing_id, existing_qty + qty)
self._satchel_set_stacks(player, stacks)
return True
if len(stacks) >= self.world.settings.satchel_max:
return False
ids.append(item_id)
self._satchel_set(player, ids)
stacks.append((item_id, qty))
self._satchel_set_stacks(player, stacks)
return True
def _satchel_spend(self, player: Player, index: int, qty: int = 1) -> None:
"""Decrement the stack at *index* by *qty*, dropping it when it empties.
The single home for taking from a stack: used by quaff, the death-save,
and the forge (ore). The empty-stack drop is handled by
:meth:`_satchel_set_stacks`, so a spent-to-zero stack falls away.
"""
stacks = self._satchel_stacks(player)
item_id, have = stacks[index]
stacks[index] = (item_id, have - qty)
self._satchel_set_stacks(player, stacks)
def _apply_rare_kill(
self, player: Player, monster: Monster, lines: list[str]
) -> list[EventSpec]:
@@ -758,6 +804,26 @@ class Game:
lines.append("It guarded a draught — but your satchel had no room.")
return [self._herald("rare_kill", player.name, monster=monster.name)]
def _grant_ore(self, player: Player, qty: int, lines: list[str]) -> None:
"""Drop *qty* forge ore into the satchel on a won fight, narrating it.
The ore goes through the same stack-add chokepoint as every other
satchel add, so it bumps an existing ore stack (always fits) or opens a
new stack while a distinct slot is free. When the bag can hold no ore
(no ore stack AND the distinct-stack cap is full), the ore is simply
DROPPED with a "no room" note — never an error, and never a turn lost.
A non-positive *qty* (a pack tuned to 0, or an unlucky 0 forest roll) is
a no-op, so no empty "you find ore" line is spliced in.
"""
if qty <= 0:
return
ore = self.world.item_by_id(self.world.settings.forge_ore_item)
ore_name = ore.name if ore is not None else self.world.settings.forge_ore_item
if self._satchel_try_add(player, self.world.settings.forge_ore_item, qty):
lines.append(f"You pry {qty} {ore_name} from the wreck and pocket it.")
else:
lines.append(f"You spy {ore_name} in the wreck, but you've no room to pocket the ore.")
def _apply_fight(
self, player: Player, result: combat.FightResult, monster: Monster | None = None
) -> str:
@@ -769,6 +835,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))
# A forest kill sometimes turns up forge ore (the deep is the surer
# source; here it is an occasional bonus on a won bout).
if self.rng.chance(self.world.settings.ore_forest_chance):
self._grant_ore(player, 1, lines)
if monster is not None and monster.rare:
events.extend(self._apply_rare_kill(player, monster, lines))
@@ -918,6 +988,10 @@ class Game:
return self._leave(player)
if verb == "rest":
return self._rest(player)
if verb == "deposit":
return self._deposit(player, amount)
if verb == "withdraw":
return self._withdraw(player, amount)
if verb == "heal":
return self._heal(player)
if verb == "buy":
@@ -952,6 +1026,57 @@ class Game:
player, lines=[f"You can't afford the {cost}-gold bed. (You have {player.gold}.)"]
)
# -- the Vault: bank gold at the inn (safe from ambush) --------------
def _deposit(self, player: Player, amount: int) -> str:
"""Move *amount* gold from the hand into the inn strongbox (no turn).
Banked gold is SAFE from ambush and survives the Wyrm-win legacy reset.
Refuses without mutation when there is nothing to bank or the amount
exceeds the carried gold; both refusals are friendly and in-fiction.
"""
if player.gold <= 0:
return self._location_menu(player, lines=["You've no coin in hand to bank."])
if amount < 1 or amount > player.gold:
return self._location_menu(
player,
lines=[f"Name an amount from 1 to {player.gold} to set aside."],
)
player.gold -= amount
player.banked += amount
self._persist(player)
return self._location_menu(
player,
lines=[
f"The innkeep counts your coin into the strongbox. "
f"({amount} banked; {player.banked} in the vault, {player.gold} in hand.)"
],
)
def _withdraw(self, player: Player, amount: int) -> str:
"""Move *amount* gold from the inn strongbox back into the hand (no turn).
Refuses without mutation when the vault is empty or the amount exceeds
what is banked; both refusals are friendly and in-fiction.
"""
if player.banked <= 0:
return self._location_menu(player, lines=["Your vault stands empty."])
if amount < 1 or amount > player.banked:
return self._location_menu(
player,
lines=[f"You may draw 1 to {player.banked} gold from the vault."],
)
player.banked -= amount
player.gold += amount
self._persist(player)
return self._location_menu(
player,
lines=[
f"The innkeep counts coin from the strongbox into your hand. "
f"({amount} drawn; {player.gold} in hand, {player.banked} in the vault.)"
],
)
def _heal(self, player: Player) -> str:
per_hp = self.world.settings.heal_cost_per_hp
missing = player.max_hp - player.hp
@@ -1148,10 +1273,10 @@ class Game:
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)
stacks = self._satchel_stacks(player)
best = self._strongest_potion(stacks)
if best is None:
return self._surface(player, lines=["Your satchel is empty."])
return self._surface(player, lines=["Your satchel holds no draught to quaff."])
if player.hp >= player.max_hp:
return self._surface(
player,
@@ -1160,8 +1285,7 @@ class Game:
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._satchel_spend(player, index)
self._persist(player)
return self._surface(
player,
@@ -1171,14 +1295,17 @@ class Game:
# -- 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.
"""Enhance the equipped weapon or armour by +1 the late-game gold+ore 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,
``target`` is "weapon" or "armour"/"armor". A +1 step costs
``forge_base_cost * (current_plus + 1)`` GOLD *and*
``(current_plus + 1) * forge_ore_per_plus`` of the world's forge ore
(carried in the satchel, won in the deep). The plus is capped at
``forge_max_plus``. On success BOTH the gold and the ore are 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.
armour→def). A forge short of gold or ore refuses without mutation,
naming both requirements; a capped slot refuses; a missing/invalid
target asks which slot.
"""
slot = target.strip().lower()
if slot == "weapon":
@@ -1199,25 +1326,34 @@ class Game:
label: str,
apply: Callable[[Player], None],
) -> str:
"""Shared forge accounting for one slot: cap, cost, deduct, apply."""
"""Shared forge accounting for one slot: cap, gold+ore 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:
ore_need = (current + 1) * settings.forge_ore_per_plus
ore = self.world.item_by_id(settings.forge_ore_item)
ore_name = ore.name if ore is not None else settings.forge_ore_item
found = self._satchel_find(player, settings.forge_ore_item)
ore_have = found[1] if found is not None else 0
if player.gold < cost or ore_have < ore_need:
return self._location_menu(
player,
lines=[
f"The smith wants {cost} gold to better your {label}; you hold {player.gold}."
f"The smith wants {cost} gold and {ore_need} {ore_name} to better your "
f"{label} — you hold {player.gold} gold and {ore_have} {ore_name}."
],
)
player.gold -= cost
if ore_need > 0 and found is not None:
self._satchel_spend(player, found[0], ore_need)
apply(player)
self._persist(player)
new_plus = player.weapon_plus if label == "weapon" else player.armor_plus
ore_note = f", -{ore_need} {ore_name}" if ore_need > 0 else ""
return self._location_menu(
player,
lines=[f"The smith works your {label} to +{new_plus}. (-{cost} gold)"],
lines=[f"The smith works your {label} to +{new_plus}. (-{cost} gold{ore_note})"],
)
@staticmethod
@@ -1350,6 +1486,9 @@ 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))
# The deep is the reliable ore source: a cleared rung always yields
# forge ore (the forge gate is built to be fed by descending).
self._grant_ore(player, self.world.settings.ore_dungeon_drop, lines)
player.deepest_rung = next_rung + 1
lines.append("")
if player.deepest_rung >= floor:
@@ -1490,13 +1629,16 @@ class Game:
def _reset_with_legacy(self, player: Player) -> None:
"""Reincarnate *player* to fresh-start values, banking the win as legend.
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 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.
Stats, equipment, HP, carried gold, level/xp and position all return to
the first-day baseline and ``created_at`` is restamped; ``wins`` rises
by 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. The VAULT (``banked``)
SURVIVES, too: it is gold set aside in the strongbox, not on the reborn
hero, so it persists across runs as a small standing reward — the only
wealth, besides the ★, that a legacy reset does not clear.
"""
settings = self.world.settings
# Clear both gear slots through the shared unequip helpers FIRST, so the
+5 -1
View File
@@ -69,6 +69,7 @@ _PLAYER_COLUMNS = (
"satchel",
"weapon_plus",
"armor_plus",
"banked",
)
@@ -117,7 +118,8 @@ class Store:
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
armor_plus INTEGER NOT NULL DEFAULT 0,
banked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (
@@ -338,6 +340,7 @@ def _player_to_row(player: Player) -> tuple[object, ...]:
player.satchel,
player.weapon_plus,
player.armor_plus,
player.banked,
)
@@ -373,6 +376,7 @@ def _row_to_player(row: sqlite3.Row) -> Player:
satchel=row["satchel"],
weapon_plus=row["weapon_plus"],
armor_plus=row["armor_plus"],
banked=row["banked"],
)
+28 -10
View File
@@ -130,10 +130,13 @@ DELVING DEEP (the reasons to come back)
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
* THE FORGE — GOLD AND ORE. The shop's forge adds 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.
dearer than the last. A step costs gold AND forge ore — a material the hero
EARNS in combat, never buys: every cleared dungeon rung drops some, and a won
forest fight sometimes turns up a little. So the forge is fed by descending,
not just by a fat purse; a hero short of ore is told so. Swapping or selling
a forged 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
@@ -190,6 +193,15 @@ THE SOCIAL LAYER (rivals, mail, and dice)
is capped per day. A big win is heralded; a quiet one is just a story you
tell. Remind players the house has no mercy and the odds are even at best.
THE VAULT (banking coin at the inn)
The inn keeps a strongbox. DEPOSIT (door_action action="deposit" amount=<gold>)
moves coin from the hero's hand into the vault; WITHDRAW (action="withdraw"
amount=<gold>) draws it back. Neither costs a turn. Two things make the vault
matter: banked gold is SAFE FROM AMBUSH (a sleeping-robber lifts only what the
victim carries), so banking before logging off is the way to protect a purse;
and banked gold SURVIVES THE WYRM-WIN RESET — it is the one wealth a reborn
hero keeps, alongside their ★. Suggest a wary player bank their winnings.
TOOL CHEAT-SHEET
door_help This manual.
door_join(player) Sign in (creates or resumes a character).
@@ -198,7 +210,8 @@ 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, forge (a +1 edge), heal,
rest, deposit/withdraw (the inn vault), buy,
sell, forge (a +1 edge, gold + ore), heal,
gamble (dice at the inn), descend (one rung),
challenge (the Wyrm), post (a note), quaff (a
carried potion), leave.
@@ -375,9 +388,12 @@ def door_action(
robbery in the spirit of the classic door-game player-kill (target=<name>, spends a turn).
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:
dice (amount=<gold>), or bank coin in the vault with 'deposit'/'withdraw'
(amount=<gold>) — banked gold is safe from ambush and survives a Wyrm-win
reset. Buying a potion now stows it in your satchel rather than drinking it;
'forge' (target="weapon"/"armour", at the shop) spends gold AND forge ore
(won in the deep) 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
@@ -392,13 +408,15 @@ def door_action(
Args:
player: The adventurer's name.
action: The verb to attempt (fight, flee, ambush, rest, buy, sell,
forge, heal, gamble, descend, challenge, post, quaff, leave).
action: The verb to attempt (fight, flee, ambush, rest, deposit,
withdraw, buy, sell, 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.
amount: For inn 'gamble', the gold wagered on the dice; for the vault
'deposit'/'withdraw', the gold moved.
"""
blank = _guard_name(player)
if blank is not None:
+50 -9
View File
@@ -31,6 +31,7 @@ from typing import TYPE_CHECKING, TextIO
from understone.engine import turns
from understone.engine.models import Item, Mode, Monster, Player, Slot
from understone.engine.rng import GameRNG
from understone.engine.satchel import decode_satchel
from understone.game import Game
from understone.persistence import Store
from understone.world.loader import load_world
@@ -249,12 +250,19 @@ class _Bot:
return self._errand("shop", "buy", item=target.item_id)
def _stock_satchel(self) -> bool:
"""Top the satchel up with the strongest affordable potion, if flush."""
"""Top the satchel up with the strongest affordable potion, if flush.
Counts POTIONS carried (across consumable stacks), not distinct stacks:
the bot wants a small reserve of draughts for the death-save, and since
v0.10 potions of one kind stack, the cap is read as "carry up to
``satchel_max`` draughts" — which also bounds the buy loop (buying the
same potion bumps one stack, so a stack-count gate would never fill).
Ore the bot wins in the deep shares the bag but is not a draught, so it
never blocks topping up potions here.
"""
if not self._shop_offers("buy"):
return False
player = self._player()
carried = [c for c in player.satchel.split(",") if c]
if len(carried) >= self.world.settings.satchel_max:
if self._potions_carried() >= self.world.settings.satchel_max:
return False
potion = self._best_affordable_potion()
if potion is None:
@@ -262,11 +270,14 @@ class _Bot:
return self._errand("shop", "buy", item=potion.item_id)
def _forge_edge(self) -> bool:
"""Forge a +1 edge on weapon then armour when gold is plentiful.
"""Forge a +1 edge on weapon then armour when gold AND ore are plentiful.
Only fires once the bot is genuinely flush (a forge is the late-game
gold sink), and only when its gear is already the best the shop sells,
so it never forges a blade it is about to replace.
gold sink), only when its gear is already the best the shop sells (so it
never forges a blade it is about to replace), and only when it actually
holds the ORE the +1 step costs — ore is won in the deep, so the bot
forges as the deep feeds it, exactly as real play does. Without the ore
for a step it skips it rather than spinning on a forge it cannot pay.
"""
if not self._shop_offers("forge"):
return False
@@ -274,6 +285,7 @@ class _Bot:
player = self._player()
if not self._gear_is_best():
return False
ore_have = self._satchel_qty(settings.forge_ore_item)
for current, slot_arg in (
(player.weapon_plus, "weapon"),
(player.armor_plus, "armour"),
@@ -281,7 +293,8 @@ class _Bot:
if current >= settings.forge_max_plus:
continue
cost = settings.forge_base_cost * (current + 1)
if not self._can_afford(cost):
ore_need = (current + 1) * settings.forge_ore_per_plus
if not self._can_afford(cost) or ore_have < ore_need:
continue
return self._errand("shop", "forge", target=slot_arg)
return False
@@ -650,7 +663,7 @@ class _Bot:
player = self._player()
turn_actions = self.fights_fought + self.descents + self.challenges
fight_share = self.fights_fought / turn_actions if turn_actions else 0.0
satchel = tuple(c for c in player.satchel.split(",") if c)
satchel = tuple(f"{item_id}×{qty}" for item_id, qty in self._satchel_stacks(player.satchel))
return BalanceReport(
days=days,
seed=seed,
@@ -676,6 +689,34 @@ class _Bot:
def _player(self) -> Player:
return self.game.players[self.name]
@staticmethod
def _satchel_stacks(satchel: str) -> list[tuple[str, int]]:
"""Decode the player's ``"id:qty"`` satchel into ``(id, qty)`` stacks.
Delegates to the shared
:func:`~understone.engine.satchel.decode_satchel` codec, so the bot
reasons about its own bag (potion reserve, ore on hand) over the same
parse the game façade uses — without reaching into private façade helpers.
"""
return decode_satchel(satchel)
def _satchel_qty(self, item_id: str) -> int:
"""Return how many of *item_id* the bot carries (0 if none)."""
return sum(
qty
for stack_id, qty in self._satchel_stacks(self._player().satchel)
if stack_id == item_id
)
def _potions_carried(self) -> int:
"""Return the total number of consumable draughts in the satchel."""
total = 0
for item_id, qty in self._satchel_stacks(self._player().satchel):
item = self.world.item_by_id(item_id)
if item is not None and item.slot is Slot.CONSUMABLE:
total += qty
return total
def _turns_left(self) -> int:
return self._player().turns_left
+45
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from understone.engine.models import Mode
from understone.engine.satchel import decode_satchel
from understone.screen import texture
if TYPE_CHECKING:
@@ -96,6 +97,9 @@ def build_state_payload(game: Game) -> dict[str, object]:
"hp": p.hp,
"max_hp": p.max_hp,
"mode": p.mode.value if isinstance(p.mode, Mode) else str(p.mode),
"gold": p.gold,
"banked": p.banked,
"satchel": _satchel_entries(game, p.satchel),
}
for p in game.players.values()
]
@@ -119,6 +123,23 @@ def build_state_payload(game: Game) -> dict[str, object]:
}
def _satchel_entries(game: Game, satchel: str) -> list[dict[str, object]]:
"""Decode a player's ``"id:qty"`` satchel into ``[{"name", "qty"}, ...]``.
Decodes the bag through the shared
:func:`~understone.engine.satchel.decode_satchel` codec, then resolves each
stack's id to its display name via the world's item table; an id no longer in
the pack (a save edited out from under it) falls back to the raw id, so the
lobby TV never shows a blank entry. The Watch is read-only, so it only
decodes the name-resolution is the only work that lives here.
"""
entries: list[dict[str, object]] = []
for item_id, qty in decode_satchel(satchel):
item = game.world.item_by_id(item_id)
entries.append({"name": item.name if item is not None else item_id, "qty": qty})
return entries
def _recent_events(game: Game) -> list[Event]:
"""Return the last :data:`_HERALD_LIMIT` resident PUBLIC events, oldest-first.
@@ -288,6 +309,7 @@ _WATCH_HTML_TEMPLATE = """\
ul { margin: 0; padding: 0; list-style: none; }
li { padding: 2px 0; }
.muted { color: var(--phosphor-dim); }
.subline { font-size: 12px; padding-left: 2px; }
.adv-name { color: var(--amber); }
.stars { color: var(--amber); letter-spacing: 1px; }
.feed li { border-bottom: 1px dotted var(--edge); padding: 4px 0; }
@@ -554,10 +576,33 @@ _WATCH_HTML_TEMPLATE = """\
rest.className = "muted";
rest.textContent = " Lv" + p.level + " HP " + p.hp + "/" + p.max_hp;
li.appendChild(rest);
// A dim sub-line: gold on hand and (if any) gold in the vault. The whole
// shared world is on the lobby TV, so every hero's purse is public here.
var gold = document.createElement("div");
gold.className = "muted subline";
var goldText = (p.gold || 0) + "g";
if (p.banked) { goldText += " +" + p.banked + " vault"; }
gold.textContent = goldText;
li.appendChild(gold);
// A second dim sub-line: the satchel stacks ("Name ×qty"), or empty.
var sat = document.createElement("div");
sat.className = "muted subline";
sat.textContent = satchelText(p.satchel || []);
li.appendChild(sat);
list.appendChild(li);
}
}
// Render the satchel stacks as a compact dot-joined line, or an empty note.
function satchelText(stacks) {
if (!stacks.length) { return "satchel empty"; }
var parts = [];
for (var i = 0; i < stacks.length; i++) {
parts.push(stacks[i].name + " \\u00d7" + stacks[i].qty);
}
return parts.join(" \\u00b7 ");
}
function renderHall(hall) {
var list = document.getElementById("hall");
while (list.firstChild) { list.removeChild(list.firstChild); }
@@ -75,5 +75,11 @@
"slot": "consumable",
"heal": 70,
"price": 60
},
{
"id": "iron_ore",
"name": "Iron Ore",
"slot": "material",
"price": 0
}
]
@@ -4,11 +4,12 @@
"name": "The Sleeping Drake",
"glyph": "⌂",
"color": "inn",
"actions": ["rest", "gamble", "leave"],
"actions": ["rest", "deposit", "withdraw", "gamble", "leave"],
"flavor": [
"Lamplight pools on worn oak tables.",
"The innkeeper nods toward the hearth.",
"A night's rest restores you fully.",
"An iron strongbox by the bar keeps coin safe from sleeping-robbers.",
"In the corner, a dice cup waits for a wager."
]
},
@@ -127,6 +127,10 @@
"forge_base_cost": 60,
"forge_max_plus": 3,
"rare_drop_item": "greater_potion",
"forge_ore_item": "iron_ore",
"forge_ore_per_plus": 1,
"ore_dungeon_drop": 2,
"ore_forest_chance": 0.2,
"watch_theme": "phosphor"
}
}
@@ -57,6 +57,11 @@ SETTINGS_BANDS: dict[str, tuple[int, int | None]] = {
"satchel_max": (1, 10),
"forge_base_cost": (1, 10000),
"forge_max_plus": (0, 10),
# v0.10 the ore-gated forge: ore per +1 step, and the guaranteed ore drop on
# a won dungeon rung. (forge_ore_item is a cross-ref, ore_forest_chance is a
# float — both validated below, outside this int-band loop.)
"forge_ore_per_plus": (0, 10),
"ore_dungeon_drop": (0, 20),
}
# Per-kind amount bands for the overworld event table (inclusive).
@@ -617,6 +622,8 @@ 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)
forge_ore_item = _decode_forge_ore_item(spec, items)
ore_forest_chance = _decode_ore_forest_chance(spec)
watch_theme = _decode_watch_theme(spec)
return Settings(
@@ -647,6 +654,10 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons
forge_base_cost=values["forge_base_cost"],
forge_max_plus=values["forge_max_plus"],
rare_drop_item=rare_drop_item,
forge_ore_item=forge_ore_item,
forge_ore_per_plus=values["forge_ore_per_plus"],
ore_dungeon_drop=values["ore_dungeon_drop"],
ore_forest_chance=ore_forest_chance,
watch_theme=watch_theme,
)
@@ -694,6 +705,44 @@ def _decode_rare_drop_item(spec: dict[str, Any], items: list[Item]) -> str:
return drop_id
def _decode_forge_ore_item(spec: dict[str, Any], items: list[Item]) -> str:
"""Resolve and validate the world's forge ore — the material the forge spends.
The id must name an item in the pack AND that item must be a ``material``
(it is carried in the satchel and spent at the forge, never equipped or
quaffed, so a weapon/armour/consumable id would be incoherent). Mirrors the
``rare_drop_item`` check but pins the slot to :attr:`Slot.MATERIAL`.
"""
ore_id = str(_require(spec, "forge_ore_item", "world.json settings"))
by_id = {it.item_id: it for it in items}
item = by_id.get(ore_id)
if item is None:
raise WorldLoadError(
f"world.json settings.forge_ore_item = {ore_id!r} is not a known item id"
)
if item.slot is not Slot.MATERIAL:
raise WorldLoadError(
f"world.json settings.forge_ore_item = {ore_id!r} must be a material item, "
f"not {item.slot.value!r}"
)
return ore_id
def _decode_ore_forest_chance(spec: dict[str, Any]) -> float:
"""Resolve and validate the per-win forest ore chance (a 0.0..1.0 float).
The chance a won forest fight yields one ore. A float, so it is validated
here rather than through the integer :data:`SETTINGS_BANDS` loop, mirroring
the ``encounter_rate`` probability check in terrain.
"""
chance = float(_require(spec, "ore_forest_chance", "world.json settings"))
if not 0.0 <= chance <= 1.0:
raise WorldLoadError(
f"world.json settings.ore_forest_chance = {chance} is out of band (0.0..1.0)"
)
return chance
def _decode_watch_theme(spec: dict[str, Any]) -> str:
"""Resolve and validate the Watch CRT palette name (optional, defaulted).
@@ -75,5 +75,11 @@
"slot": "consumable",
"heal": 70,
"price": 60
},
{
"id": "slag_iron",
"name": "Slag-Iron",
"slot": "material",
"price": 0
}
]
@@ -4,11 +4,12 @@
"name": "The Forge-Rest",
"glyph": "⌂",
"color": "inn",
"actions": ["rest", "gamble", "leave"],
"actions": ["rest", "deposit", "withdraw", "gamble", "leave"],
"flavor": [
"Heat-bricked walls hold back the ashfall outside.",
"The hearthwright stokes a banked forge and nods you toward a cot.",
"A night beside the coals restores you fully.",
"A slag-iron strongbox by the hearth keeps coin safe from sleeping-robbers.",
"In the corner, a cup of knucklebones rattles for a wager."
]
},
@@ -127,6 +127,10 @@
"forge_base_cost": 60,
"forge_max_plus": 3,
"rare_drop_item": "cooling_draught",
"forge_ore_item": "slag_iron",
"forge_ore_per_plus": 1,
"ore_dungeon_drop": 2,
"ore_forest_chance": 0.2,
"watch_theme": "ember"
}
}