From 393a6fc2b27ca0f4ce7365da01a2f9fcdefac547 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 12 Jun 2026 23:48:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(examples):=20Understone=20v0.10=20?= =?UTF-8?q?=E2=80=94=20the=20satchel,=20the=20ore-forge,=20and=20the=20vau?= =?UTF-8?q?lt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- examples/door-game/README.md | 16 +- examples/door-game/pyproject.toml | 2 +- examples/door-game/tests/conftest.py | 36 +++ examples/door-game/tests/test_cli.py | 35 ++- examples/door-game/tests/test_descend.py | 276 +++++++++++++++--- examples/door-game/tests/test_package.py | 2 +- examples/door-game/tests/test_persistence.py | 14 +- examples/door-game/tests/test_satchel.py | 63 ++++ examples/door-game/tests/test_social.py | 121 ++++++++ examples/door-game/tests/test_watch.py | 54 ++++ examples/door-game/tests/test_world_loader.py | 76 +++++ examples/door-game/tests/test_wyrm.py | 60 +++- examples/door-game/understone/__init__.py | 2 +- examples/door-game/understone/cli.py | 105 ++++++- .../door-game/understone/engine/models.py | 26 +- .../door-game/understone/engine/satchel.py | 62 ++++ examples/door-game/understone/game.py | 256 ++++++++++++---- examples/door-game/understone/persistence.py | 6 +- examples/door-game/understone/server.py | 38 ++- examples/door-game/understone/sim.py | 59 +++- examples/door-game/understone/watch.py | 45 +++ .../understone/world/data/items.json | 6 + .../understone/world/data/locations.json | 3 +- .../understone/world/data/world.json | 4 + examples/door-game/understone/world/loader.py | 49 ++++ .../world/packs/cinder-wastes/items.json | 6 + .../world/packs/cinder-wastes/locations.json | 3 +- .../world/packs/cinder-wastes/world.json | 4 + 28 files changed, 1278 insertions(+), 151 deletions(-) create mode 100644 examples/door-game/tests/test_satchel.py create mode 100644 examples/door-game/understone/engine/satchel.py diff --git a/examples/door-game/README.md b/examples/door-game/README.md index 20411ad6..bb690531 100644 --- a/examples/door-game/README.md +++ b/examples/door-game/README.md @@ -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. | diff --git a/examples/door-game/pyproject.toml b/examples/door-game/pyproject.toml index 5af54f54..0251d5e2 100644 --- a/examples/door-game/pyproject.toml +++ b/examples/door-game/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "understone" -version = "0.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" diff --git a/examples/door-game/tests/conftest.py b/examples/door-game/tests/conftest.py index f7f29ac2..09336b15 100644 --- a/examples/door-game/tests/conftest.py +++ b/examples/door-game/tests/conftest.py @@ -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.""" diff --git a/examples/door-game/tests/test_cli.py b/examples/door-game/tests/test_cli.py index 6f700b9b..95443aa0 100644 --- a/examples/door-game/tests/test_cli.py +++ b/examples/door-game/tests/test_cli.py @@ -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( diff --git a/examples/door-game/tests/test_descend.py b/examples/door-game/tests/test_descend.py index 5ca937d7..775f60a4 100644 --- a/examples/door-game/tests/test_descend.py +++ b/examples/door-game/tests/test_descend.py @@ -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 diff --git a/examples/door-game/tests/test_package.py b/examples/door-game/tests/test_package.py index 40506721..11aca1fb 100644 --- a/examples/door-game/tests/test_package.py +++ b/examples/door-game/tests/test_package.py @@ -6,4 +6,4 @@ import understone def test_version_present() -> None: - assert understone.__version__ == "0.9.0" + assert understone.__version__ == "0.10.0" diff --git a/examples/door-game/tests/test_persistence.py b/examples/door-game/tests/test_persistence.py index 529dda22..dd47eedd 100644 --- a/examples/door-game/tests/test_persistence.py +++ b/examples/door-game/tests/test_persistence.py @@ -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() diff --git a/examples/door-game/tests/test_satchel.py b/examples/door-game/tests/test_satchel.py new file mode 100644 index 00000000..5b623d21 --- /dev/null +++ b/examples/door-game/tests/test_satchel.py @@ -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([]) == "" diff --git a/examples/door-game/tests/test_social.py b/examples/door-game/tests/test_social.py index 96afc347..2cc0037a 100644 --- a/examples/door-game/tests/test_social.py +++ b/examples/door-game/tests/test_social.py @@ -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 diff --git a/examples/door-game/tests/test_watch.py b/examples/door-game/tests/test_watch.py index e896222a..d9a49d28 100644 --- a/examples/door-game/tests/test_watch.py +++ b/examples/door-game/tests/test_watch.py @@ -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 diff --git a/examples/door-game/tests/test_world_loader.py b/examples/door-game/tests/test_world_loader.py index a9db36dd..3eb7fdd8 100644 --- a/examples/door-game/tests/test_world_loader.py +++ b/examples/door-game/tests/test_world_loader.py @@ -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: diff --git a/examples/door-game/tests/test_wyrm.py b/examples/door-game/tests/test_wyrm.py index 90bf464d..f74d3102 100644 --- a/examples/door-game/tests/test_wyrm.py +++ b/examples/door-game/tests/test_wyrm.py @@ -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 diff --git a/examples/door-game/understone/__init__.py b/examples/door-game/understone/__init__.py index caa79e21..e53a0f28 100644 --- a/examples/door-game/understone/__init__.py +++ b/examples/door-game/understone/__init__.py @@ -1,3 +1,3 @@ """Understone — a BBS-style ANSI door game served over MCP.""" -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/examples/door-game/understone/cli.py b/examples/door-game/understone/cli.py index f6b981b0..abbdd526 100644 --- a/examples/door-game/understone/cli.py +++ b/examples/door-game/understone/cli.py @@ -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=`) and draws it back (`withdraw amount=`). +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 diff --git a/examples/door-game/understone/engine/models.py b/examples/door-game/understone/engine/models.py index 78d21dd8..6ecc9603 100644 --- a/examples/door-game/understone/engine/models.py +++ b/examples/door-game/understone/engine/models.py @@ -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. diff --git a/examples/door-game/understone/engine/satchel.py b/examples/door-game/understone/engine/satchel.py new file mode 100644 index 00000000..ff865eb1 --- /dev/null +++ b/examples/door-game/understone/engine/satchel.py @@ -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) diff --git a/examples/door-game/understone/game.py b/examples/door-game/understone/game.py index 799f3fe6..c5627598 100644 --- a/examples/door-game/understone/game.py +++ b/examples/door-game/understone/game.py @@ -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 diff --git a/examples/door-game/understone/persistence.py b/examples/door-game/understone/persistence.py index 4da66a52..ec41e170 100644 --- a/examples/door-game/understone/persistence.py +++ b/examples/door-game/understone/persistence.py @@ -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"], ) diff --git a/examples/door-game/understone/server.py b/examples/door-game/understone/server.py index b4c76a04..ddafc8e1 100644 --- a/examples/door-game/understone/server.py +++ b/examples/door-game/understone/server.py @@ -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=) + moves coin from the hero's hand into the vault; WITHDRAW (action="withdraw" + amount=) 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=, 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=). 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=), or bank coin in the vault with 'deposit'/'withdraw' + (amount=) — 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: diff --git a/examples/door-game/understone/sim.py b/examples/door-game/understone/sim.py index 9f62e975..6a44d476 100644 --- a/examples/door-game/understone/sim.py +++ b/examples/door-game/understone/sim.py @@ -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 diff --git a/examples/door-game/understone/watch.py b/examples/door-game/understone/watch.py index 1b3bd9b4..f0e2a9b4 100644 --- a/examples/door-game/understone/watch.py +++ b/examples/door-game/understone/watch.py @@ -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); } diff --git a/examples/door-game/understone/world/data/items.json b/examples/door-game/understone/world/data/items.json index 856ba2f7..e88ec880 100644 --- a/examples/door-game/understone/world/data/items.json +++ b/examples/door-game/understone/world/data/items.json @@ -75,5 +75,11 @@ "slot": "consumable", "heal": 70, "price": 60 + }, + { + "id": "iron_ore", + "name": "Iron Ore", + "slot": "material", + "price": 0 } ] diff --git a/examples/door-game/understone/world/data/locations.json b/examples/door-game/understone/world/data/locations.json index 040083ca..79112f2c 100644 --- a/examples/door-game/understone/world/data/locations.json +++ b/examples/door-game/understone/world/data/locations.json @@ -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." ] }, diff --git a/examples/door-game/understone/world/data/world.json b/examples/door-game/understone/world/data/world.json index ba70ff3e..0c24ff9b 100644 --- a/examples/door-game/understone/world/data/world.json +++ b/examples/door-game/understone/world/data/world.json @@ -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" } } diff --git a/examples/door-game/understone/world/loader.py b/examples/door-game/understone/world/loader.py index f009baee..4ad5a068 100644 --- a/examples/door-game/understone/world/loader.py +++ b/examples/door-game/understone/world/loader.py @@ -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). diff --git a/examples/door-game/understone/world/packs/cinder-wastes/items.json b/examples/door-game/understone/world/packs/cinder-wastes/items.json index 4f8978ad..6722dbf7 100644 --- a/examples/door-game/understone/world/packs/cinder-wastes/items.json +++ b/examples/door-game/understone/world/packs/cinder-wastes/items.json @@ -75,5 +75,11 @@ "slot": "consumable", "heal": 70, "price": 60 + }, + { + "id": "slag_iron", + "name": "Slag-Iron", + "slot": "material", + "price": 0 } ] diff --git a/examples/door-game/understone/world/packs/cinder-wastes/locations.json b/examples/door-game/understone/world/packs/cinder-wastes/locations.json index 6da3c2d1..857da278 100644 --- a/examples/door-game/understone/world/packs/cinder-wastes/locations.json +++ b/examples/door-game/understone/world/packs/cinder-wastes/locations.json @@ -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." ] }, diff --git a/examples/door-game/understone/world/packs/cinder-wastes/world.json b/examples/door-game/understone/world/packs/cinder-wastes/world.json index 15984a60..b49bd9b2 100644 --- a/examples/door-game/understone/world/packs/cinder-wastes/world.json +++ b/examples/door-game/understone/world/packs/cinder-wastes/world.json @@ -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" } }