mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat(examples): Understone v0.10 — the satchel, the ore-forge, and the vault
A game-loop mechanics patch: the satchel becomes a real stacking inventory,
forging now demands ore won in combat (not just gold), and a vault lets a
hero protect coin from ambush.
- Stacking satchel: the bag re-encodes from a flat id list to "id:qty"
stacks, so potions stack (three Minor Potions fill one slot, not three)
and materials ride alongside. satchel_max now caps distinct KINDS (3);
per-kind quantity is unbounded. quaff/death-save still pull the strongest
potion and ignore materials. One pure codec (engine/satchel.py) owns the
encoding; the façade, the Watch, and the sim all decode through it — no
three-way drift (the v0.9 single-source lesson). The codec parses a bare
id as qty 1, so it can never silently drop a malformed stack.
- Ore-gated forge: ore is a material that drops from won dungeon-rung
fights (and, less often, forest fights), stacks in the satchel, and is
not buyable or sellable — you earn your edge by fighting for it. Forging
now costs gold AND ore ((plus+1) ore per tier), so a rich-but-idle hero
can no longer buy power at the dice table. The dungeon is now also the
mine.
- The vault: deposit/withdraw at the inn moves coin to a strongbox that
ambush cannot touch and that SURVIVES the Wyrm-win legacy reset — the
carry-vs-protect decision the PvP economy was missing.
- Surfaced on both the /watch lobby TV and the in-chat door_status sheet:
each hero's stacked satchel, carried gold, and vaulted gold.
- Tuning (the sim is the instrument): the ore gate added ~2 days to the
Vale and ~1.6 to the Cinder Wastes; the greedy bot still slays the Wyrm
3/3 on both, fully forged to +3/+3, so the loop is not stalled. Defaults
held — no numbers needed retuning.
Four new banded settings (forge_ore_item, forge_ore_per_plus,
ore_dungeon_drop, ore_forest_chance); both worlds gained an ore item.
Schema mutated in place (banked column, satchel re-encoding) — pre-1.0, no
migration by design; a real migration story is owed at 1.0. Tests 382 ->
419; the vault-survives-rebirth invariant and the codec are revert-verified.
(cherry picked from commit 393a6fc2b2)
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,4 +6,4 @@ import understone
|
||||
|
||||
|
||||
def test_version_present() -> None:
|
||||
assert understone.__version__ == "0.9.0"
|
||||
assert understone.__version__ == "0.10.0"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([]) == ""
|
||||
@@ -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
|
||||
|
||||
@@ -264,6 +264,47 @@ def test_state_payload_includes_joined_player(tmp_path: Path, clock: object) ->
|
||||
assert brandr["hp"] == brandr["max_hp"]
|
||||
assert brandr["mode"] == "tile"
|
||||
assert (brandr["x"], brandr["y"]) == game.world.spawn
|
||||
# v0.10: a fresh hero shows their starting gold on hand, nothing banked, and
|
||||
# an empty satchel.
|
||||
assert brandr["gold"] == game.world.settings.starting_gold
|
||||
assert brandr["banked"] == 0
|
||||
assert brandr["satchel"] == []
|
||||
|
||||
|
||||
def test_state_payload_surfaces_gold_banked_and_satchel(tmp_path: Path, clock: object) -> None:
|
||||
"""A joined hero with a stocked satchel and banked gold shows the right values.
|
||||
|
||||
The lobby TV surfaces the whole shared world, so each player's purse (gold
|
||||
on hand + vault) and satchel stacks (name + qty, resolved via the pack) ride
|
||||
the state payload.
|
||||
"""
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brandr")
|
||||
player = game.players["Brandr"]
|
||||
player.gold = 120
|
||||
player.banked = 300
|
||||
game._satchel_set_stacks(player, [("iron_ore", 5), ("minor_potion", 2)])
|
||||
|
||||
payload = watch.build_state_payload(game)
|
||||
brandr = next(p for p in payload["players"] if p["name"] == "Brandr") # type: ignore[union-attr]
|
||||
assert brandr["gold"] == 120
|
||||
assert brandr["banked"] == 300
|
||||
# Stacks resolve their display name from the pack, preserving stow order.
|
||||
assert brandr["satchel"] == [
|
||||
{"name": "Iron Ore", "qty": 5},
|
||||
{"name": "Minor Potion", "qty": 2},
|
||||
]
|
||||
|
||||
|
||||
def test_state_payload_satchel_unknown_id_falls_back_to_raw(tmp_path: Path, clock: object) -> None:
|
||||
"""A satchel id no longer in the pack falls back to the raw id, never blank."""
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brandr")
|
||||
game.players["Brandr"].satchel = "ghost_item:2" # not in the pack
|
||||
|
||||
payload = watch.build_state_payload(game)
|
||||
brandr = next(p for p in payload["players"] if p["name"] == "Brandr") # type: ignore[union-attr]
|
||||
assert brandr["satchel"] == [{"name": "ghost_item", "qty": 2}]
|
||||
|
||||
|
||||
def test_state_payload_reports_all_players_including_menu(tmp_path: Path, clock: object) -> None:
|
||||
@@ -473,6 +514,19 @@ def test_watch_html_uses_other_player_marker() -> None:
|
||||
assert "\\u263b" in watch.WATCH_HTML
|
||||
|
||||
|
||||
def test_watch_html_renders_gold_banked_and_satchel() -> None:
|
||||
"""The Adventurers panel JS references each player's gold, vault, and satchel."""
|
||||
html = watch.WATCH_HTML
|
||||
# The roster sub-lines read these state fields by name.
|
||||
assert "p.gold" in html
|
||||
assert "p.banked" in html
|
||||
assert "p.satchel" in html
|
||||
# The satchel line has a dedicated renderer with an empty-bag note.
|
||||
assert "satchelText" in html
|
||||
assert "satchel empty" in html
|
||||
assert "vault" in html
|
||||
|
||||
|
||||
def test_watch_html_has_day_phase_machinery() -> None:
|
||||
"""The dusk/dawn glow-up is wired: the tint classes and the UTC-hour read."""
|
||||
html = watch.WATCH_HTML
|
||||
|
||||
@@ -621,6 +621,75 @@ def test_forge_base_cost_out_of_band_rejected(tmp_path: Path) -> None:
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_forge_ore_item_unknown_rejected(tmp_path: Path) -> None:
|
||||
"""A forge_ore_item that names no item is rejected with the item-id message."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["forge_ore_item"] = "no_such_ore"
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match="forge_ore_item = 'no_such_ore' is not a known"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_forge_ore_item_non_material_rejected(tmp_path: Path) -> None:
|
||||
"""A forge_ore_item that names a non-material (a potion) is rejected.
|
||||
|
||||
Ore is carried in the satchel and spent at the forge, never equipped or
|
||||
quaffed, so a consumable/weapon/armour id is incoherent — the loader pins
|
||||
the slot to ``material`` (mirroring the rare_drop_item consumable check).
|
||||
"""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["forge_ore_item"] = "greater_potion" # a draught, not ore
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(
|
||||
WorldLoadError, match="forge_ore_item = 'greater_potion' must be a material"
|
||||
):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_forge_ore_per_plus_out_of_band_rejected(tmp_path: Path) -> None:
|
||||
"""forge_ore_per_plus above its 0..10 band is a load error."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["forge_ore_per_plus"] = 11
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match=r"forge_ore_per_plus = 11 is out of band \(0\.\.10\)"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_ore_dungeon_drop_out_of_band_rejected(tmp_path: Path) -> None:
|
||||
"""ore_dungeon_drop above its 0..20 band is a load error."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["ore_dungeon_drop"] = 21
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(WorldLoadError, match=r"ore_dungeon_drop = 21 is out of band \(0\.\.20\)"):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_ore_forest_chance_out_of_band_rejected(tmp_path: Path) -> None:
|
||||
"""ore_forest_chance outside 0.0..1.0 is a load error (it is a probability)."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
|
||||
def mutate(data: dict[str, Any]) -> None:
|
||||
data["settings"]["ore_forest_chance"] = 1.5
|
||||
|
||||
_rewrite(pack / "world.json", mutate)
|
||||
with pytest.raises(
|
||||
WorldLoadError, match=r"ore_forest_chance = 1.5 is out of band \(0.0..1.0\)"
|
||||
):
|
||||
load_world(pack)
|
||||
|
||||
|
||||
def test_monster_zero_weight_rejected(tmp_path: Path) -> None:
|
||||
"""A monster weight of 0 is rejected (the weighted pick needs a positive total)."""
|
||||
pack = _clone_pack(tmp_path)
|
||||
@@ -648,6 +717,13 @@ def test_shipped_pack_carries_rares_and_weights() -> None:
|
||||
assert world.settings.forge_base_cost == 60
|
||||
assert world.settings.forge_max_plus == 3
|
||||
assert world.settings.dungeon_tiers == (3, 4, 5)
|
||||
# v0.10 ore-forge settings resolve, and the forge ore is a material item.
|
||||
assert world.settings.forge_ore_item == "iron_ore"
|
||||
ore = world.item_by_id(world.settings.forge_ore_item)
|
||||
assert ore is not None and ore.slot.value == "material"
|
||||
assert world.settings.forge_ore_per_plus == 1
|
||||
assert world.settings.ore_dungeon_drop == 2
|
||||
assert world.settings.ore_forest_chance == 0.2
|
||||
|
||||
|
||||
def test_monster_weight_and_rare_default_when_omitted(tmp_path: Path) -> None:
|
||||
|
||||
@@ -20,7 +20,12 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import fixed_clock, utc
|
||||
from tests.conftest import (
|
||||
fixed_clock,
|
||||
satchel_ids,
|
||||
set_satchel,
|
||||
utc,
|
||||
)
|
||||
from understone.engine.models import Mode
|
||||
from understone.engine.rng import GameRNG
|
||||
from understone.game import Game
|
||||
@@ -29,6 +34,11 @@ from understone.world.loader import load_world
|
||||
|
||||
PACK = Path(__file__).resolve().parents[1] / "understone" / "world" / "data"
|
||||
|
||||
# Module-local aliases for the shared satchel helpers, keeping the existing
|
||||
# call sites (_set_satchel / _satchel_ids) unchanged.
|
||||
_set_satchel = set_satchel
|
||||
_satchel_ids = satchel_ids
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock() -> object:
|
||||
@@ -41,6 +51,10 @@ def _game(tmp_path: Path, clock: object, seed: int = 7) -> Game:
|
||||
return Game(world, store, clock=clock, rng=GameRNG(seed=seed)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# The flat-id-list satchel helpers (_set_satchel / _satchel_ids) live in
|
||||
# tests/conftest.py now, shared with the descend suite; they are imported above.
|
||||
|
||||
|
||||
def _at_dungeon(game: Game, name: str) -> object:
|
||||
"""Place an already-joined player inside the dungeon menu, at the deep floor.
|
||||
|
||||
@@ -217,6 +231,42 @@ def test_challenge_win_resets_with_legacy(tmp_path: Path, clock: object) -> None
|
||||
assert player.bestow_spent == 7
|
||||
|
||||
|
||||
def test_challenge_win_legacy_reset_spares_the_vault(tmp_path: Path, clock: object) -> None:
|
||||
"""The vault SURVIVES a Wyrm-win rebirth; carried gold resets to starting.
|
||||
|
||||
Banked gold is the one wealth (besides the ★) a legacy reset does not clear:
|
||||
the strongbox is the inn's, not the reborn hero's. This deposits gold into
|
||||
the vault through the inn, drives a Wyrm WIN, and asserts ``banked`` is
|
||||
UNCHANGED while ``gold`` drops back to ``starting_gold``.
|
||||
|
||||
Negative-check (the revert-and-observe-failure discipline of this module):
|
||||
the implementer temporarily added ``player.banked = 0`` to
|
||||
Game._reset_with_legacy; this test then FAILED on the unchanged-``banked``
|
||||
assertion (the vault was wiped by the rebirth). The line was restored, so
|
||||
this test is the standing regression that the vault outlives the reset.
|
||||
"""
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brak")
|
||||
player = game.players["Brak"]
|
||||
# Bank some gold through the real inn path, then stand at the dungeon floor.
|
||||
player.gold = 200
|
||||
player.mode = Mode.MENU
|
||||
player.at_location = "inn"
|
||||
game.action("Brak", "deposit", "", "", amount=120)
|
||||
assert player.banked == 120 and player.gold == 80 # vault holds; hand drained
|
||||
|
||||
player = _at_dungeon(game, "Brak")
|
||||
player.level = game.world.settings.wyrm_min_level
|
||||
player.atk, player.def_, player.hp, player.max_hp = 500, 100, 500, 500
|
||||
|
||||
out = game.action("Brak", "challenge", "", "")
|
||||
|
||||
assert "freed the vale" in out.lower() # a genuine win drove the reset
|
||||
assert player.wins == 1
|
||||
assert player.banked == 120 # the vault is untouched by the rebirth
|
||||
assert player.gold == game.world.settings.starting_gold # carried wealth resets
|
||||
|
||||
|
||||
def test_challenge_win_star_in_rank_and_hall(tmp_path: Path, clock: object) -> None:
|
||||
"""After a win, door_rank shows the ★ and renders the Hall of Legends."""
|
||||
game = _game(tmp_path, clock)
|
||||
@@ -304,7 +354,7 @@ def test_challenge_loss_with_potion_survives_no_legacy_reset(tmp_path: Path, clo
|
||||
player = _doomed_wyrm_challenger(game, "Brak")
|
||||
potion = game.world.item_by_id("greater_potion")
|
||||
assert potion is not None
|
||||
game._satchel_set(player, ["greater_potion"])
|
||||
_set_satchel(game, player, ["greater_potion"])
|
||||
floor = len(game.world.settings.dungeon_tiers)
|
||||
spawn = game.world.spawn
|
||||
before_turns = player.turns_left
|
||||
@@ -317,7 +367,7 @@ def test_challenge_loss_with_potion_survives_no_legacy_reset(tmp_path: Path, clo
|
||||
assert player.hp == min(player.max_hp, potion.heal)
|
||||
assert (player.x, player.y) != spawn # NOT bounced to the spawn
|
||||
assert player.mode is Mode.MENU # still standing at the dungeon
|
||||
assert game._satchel_list(player) == [] # the draught was spent
|
||||
assert _satchel_ids(game, player) == [] # the draught was spent
|
||||
assert "death's edge" in out.lower() # the spliced survival line
|
||||
assert player.turns_left == before_turns - 1 # the challenge still cost a turn
|
||||
# No win, so NO legacy reset: level, gold, and depth all stand.
|
||||
@@ -349,7 +399,7 @@ def test_challenge_loss_potion_negative_without_save_devours(
|
||||
game = _game(tmp_path, clock)
|
||||
game.join("Brak")
|
||||
player = _doomed_wyrm_challenger(game, "Brak")
|
||||
game._satchel_set(player, ["greater_potion"])
|
||||
_set_satchel(game, player, ["greater_potion"])
|
||||
floor = len(game.world.settings.dungeon_tiers)
|
||||
spawn = game.world.spawn
|
||||
|
||||
@@ -360,7 +410,7 @@ def test_challenge_loss_potion_negative_without_save_devours(
|
||||
assert (player.x, player.y) == spawn
|
||||
assert player.mode is Mode.TILE
|
||||
assert player.deepest_rung == floor # a defeat keeps depth (no reset, no advance)
|
||||
assert game._satchel_list(player) == ["greater_potion"] # the draught is UNSPENT
|
||||
assert _satchel_ids(game, player) == ["greater_potion"] # the draught is UNSPENT
|
||||
assert "death's edge" not in out.lower() # no save, no dramatic line
|
||||
assert game.events[-1].kind == "wyrm_lose" # the devouring beat, not the survival one
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Understone — a BBS-style ANSI door game served over MCP."""
|
||||
|
||||
__version__ = "0.9.0"
|
||||
__version__ = "0.10.0"
|
||||
|
||||
@@ -269,9 +269,69 @@ def _render_bands() -> str:
|
||||
f"error naming the legal set."
|
||||
)
|
||||
|
||||
parts.append("\n### The ore-gated forge (`world.json` → `settings`)\n")
|
||||
ore_per = loader.SETTINGS_BANDS["forge_ore_per_plus"]
|
||||
dungeon = loader.SETTINGS_BANDS["ore_dungeon_drop"]
|
||||
parts.append(
|
||||
"Forging a +1 edge now costs both GOLD and ORE — a `material` item the "
|
||||
"hero earns in combat, never buys. Four settings bind it:"
|
||||
)
|
||||
parts.append(
|
||||
"* `forge_ore_item` — REQUIRED. The item id of your world's forge ore; "
|
||||
"it must name an `items.json` entry whose `slot` is `material` (an "
|
||||
"unknown id or a non-material slot is a load error). The Vale uses "
|
||||
"`iron_ore`."
|
||||
)
|
||||
parts.append(
|
||||
f"* `forge_ore_per_plus` — band `{ore_per[0]}..{ore_per[1]}`. Ore per +1 "
|
||||
f"step: a +N forge costs `(current_plus + 1) * forge_ore_per_plus` ore. "
|
||||
f"{_forge_ore_worked_example()}"
|
||||
)
|
||||
parts.append(
|
||||
f"* `ore_dungeon_drop` — band `{dungeon[0]}..{dungeon[1]}`. Ore granted "
|
||||
f"on every WON dungeon rung — the reliable source. The Vale drops 2."
|
||||
)
|
||||
parts.append(
|
||||
"* `ore_forest_chance` — a `0.0`..`1.0` probability (a float, validated "
|
||||
"outside the integer band table). The chance a WON forest fight yields "
|
||||
"one ore — the occasional bonus source. The Vale uses `0.2`."
|
||||
)
|
||||
parts.append(
|
||||
"\nOre rides the satchel as a stack, so it shares the `satchel_max` "
|
||||
"DISTINCT-stack budget with potions (per-stack quantity is unbounded). "
|
||||
"Tune the two sources so a hero who descends steadily earns enough ore "
|
||||
"to forge without grinding — the `simulate` bot will tell you if the "
|
||||
"gate stalls a winnable run."
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _forge_ore_worked_example() -> str:
|
||||
"""Render the per-step ore costs from the bundled Vale's live forge settings.
|
||||
|
||||
The starter template :func:`cli_newpack` copies IS the bundled Vale, so the
|
||||
worked figures are computed from its actual ``forge_ore_per_plus`` and
|
||||
``forge_max_plus`` rather than hardcoded — a retune of the template moves
|
||||
the manual with it. The steps are ``per_plus * (i + 1)`` for each ``i`` in
|
||||
``range(forge_max_plus)``; the total is what it costs to max one slot.
|
||||
"""
|
||||
settings = loader.load_world(PACKAGED_WORLD_DIR).settings
|
||||
per_plus = settings.forge_ore_per_plus
|
||||
max_plus = settings.forge_max_plus
|
||||
steps = [per_plus * (i + 1) for i in range(max_plus)]
|
||||
if not steps:
|
||||
return (
|
||||
f"At the template's value of {per_plus}, slots cannot be forged (`forge_max_plus` 0)."
|
||||
)
|
||||
ladder = ", ".join(str(cost) for cost in steps)
|
||||
total = sum(steps)
|
||||
return (
|
||||
f"At the template's value of {per_plus}, the steps cost {ladder} ore "
|
||||
f"({total} ore to max a slot at `forge_max_plus` {max_plus})."
|
||||
)
|
||||
|
||||
|
||||
def _reserved_glyph_list() -> list[str]:
|
||||
"""Return the reserved glyphs in a stable, readable order for the manual."""
|
||||
box = [g for g in "┌┐└┘─│═" if g in loader.RESERVED_GLYPHS]
|
||||
@@ -341,8 +401,9 @@ def _render_validate_coverage() -> str:
|
||||
"with weights `> 0`, `min <= max`, and amounts in their per-kind band.",
|
||||
"* **Cross-references** — `legend` → terrain key, location placements → "
|
||||
"`locations.json` keys, `starting_weapon`/`starting_armor` → item ids, "
|
||||
'`boss_monster` → a monster flagged `"boss": true`, and '
|
||||
"`rare_drop_item` → a consumable item id.",
|
||||
'`boss_monster` → a monster flagged `"boss": true`, '
|
||||
"`rare_drop_item` → a consumable item id, and `forge_ore_item` → a "
|
||||
"`material` item id.",
|
||||
"* **Zone tiers** — every zone's tier band must overlap at least one monster tier.",
|
||||
"* **Dungeon ladder** — every `dungeon_tiers` tier must have a non-boss "
|
||||
"monster, and that tier's FIRST monster (its fixed rung guardian) must "
|
||||
@@ -398,7 +459,8 @@ The cross-references the loader enforces:
|
||||
* `settings.starting_weapon` / `starting_armor` must be ids from
|
||||
`items.json`; `settings.boss_monster` must be an id from `monsters.json`
|
||||
that is flagged `"boss": true`; `settings.rare_drop_item` must be an id from
|
||||
`items.json` whose `slot` is `consumable`;
|
||||
`items.json` whose `slot` is `consumable`; `settings.forge_ore_item` must be
|
||||
an id from `items.json` whose `slot` is `material`;
|
||||
* every tier in `settings.dungeon_tiers` must be backed by a NON-boss monster;
|
||||
* every zone's tier band must overlap at least one monster tier.
|
||||
|
||||
@@ -462,13 +524,24 @@ first entry of a tier that backs a `dungeon_tiers` rung.
|
||||
|
||||
### `items.json`
|
||||
|
||||
A list of equipment and consumables. `slot` is `weapon`, `armor`, or
|
||||
`consumable`. Weapons add `atk`, armour adds `def`, consumables `heal`.
|
||||
A list of equipment, consumables, and crafting materials. `slot` is `weapon`,
|
||||
`armor`, `consumable`, or `material`. Weapons add `atk`, armour adds `def`,
|
||||
consumables `heal`; a `material` carries none of these — it is the forge ORE,
|
||||
carried in the satchel and spent at the forge.
|
||||
|
||||
```json
|
||||
{"id": "short_sword", "name": "Short Sword", "slot": "weapon", "atk": 5, "price": 40}
|
||||
```
|
||||
|
||||
The forge ore is a `material` item the player EARNS in combat (not the shop):
|
||||
price it `0` — ore is never bought or sold — and point `settings.forge_ore_item`
|
||||
at its id. A won dungeon rung always drops `settings.ore_dungeon_drop` of it, and
|
||||
a won forest fight has a `settings.ore_forest_chance` chance of one.
|
||||
|
||||
```json
|
||||
{"id": "iron_ore", "name": "Iron Ore", "slot": "material", "price": 0}
|
||||
```
|
||||
|
||||
### `locations.json`
|
||||
|
||||
An object keyed by location key. Each entry is a building kind with a menu of
|
||||
@@ -487,11 +560,17 @@ the verbs the engine honours inside each are:
|
||||
|
||||
| `kind` | actions the engine understands |
|
||||
| --- | --- |
|
||||
| `inn` | `rest`, `gamble`, `leave` |
|
||||
| `inn` | `rest`, `deposit`, `withdraw`, `gamble`, `leave` |
|
||||
| `shop` | `buy`, `sell`, `forge`, `leave` |
|
||||
| `healer` | `heal`, `leave` |
|
||||
| `dungeon` | `descend`, `challenge`, `leave` |
|
||||
|
||||
The inn's `deposit`/`withdraw` are the VAULT: a player banks gold into the inn
|
||||
strongbox (`deposit amount=<gold>`) and draws it back (`withdraw amount=<gold>`).
|
||||
Banked gold is SAFE from ambush — a sleeping-robber only ever lifts gold in hand
|
||||
— and it SURVIVES the Wyrm-win legacy reset, so it is the one store of wealth
|
||||
that carries across runs. Both cost no turn.
|
||||
|
||||
`quaff` (drink a satchel tonic) is legal **anywhere** and needs no menu entry.
|
||||
The `actions` list is advisory — it is the menu the narrator offers, NOT a
|
||||
validated whitelist (see "What `validate` checks" below): a verb the engine does
|
||||
@@ -598,11 +677,15 @@ tier with at least one ordinary monster.
|
||||
**The deep, the satchel, and the forge.** `dungeon_tiers` is now a RUNG LADDER
|
||||
fought one rung per `descend` — list the tiers shallow-to-deep, and make it long
|
||||
enough to feel like a journey (the Vale uses three). The Wyrm gates on reaching
|
||||
the floor as well as on level. Size the satchel with `satchel_max` (the Vale
|
||||
carries 3) — it is the death-save reserve, so keep it small. The forge is the
|
||||
late-game gold sink: `forge_base_cost` is the price of a +1 edge and scales up
|
||||
each tier (`base * (current_plus + 1)`), capped at `forge_max_plus`; price it so
|
||||
a fully-forged piece is a multi-day saving.
|
||||
the floor as well as on level. Size the satchel with `satchel_max` — it caps the
|
||||
DISTINCT stacks the bag holds (potions and ore each take a slot; per-stack
|
||||
quantity is unbounded), and it is the death-save reserve, so keep it small (the
|
||||
Vale carries 3). The forge is the late-game GOLD-AND-ORE sink: `forge_base_cost`
|
||||
is the gold price of a +1 edge and scales up each tier (`base * (current_plus +
|
||||
1)`), capped at `forge_max_plus`, and each step ALSO costs ore (see the ore-gated
|
||||
forge above). Ore is won in the deep (and seldom in the forest), so the forge is
|
||||
fed by descending — price the gold so a fully-forged piece is a multi-day saving,
|
||||
and set the ore sources so a steady delver can afford it without a grind.
|
||||
|
||||
**Rare beasts.** A rare monster is a small legend: give it a low `weight` so it
|
||||
surfaces seldom, stats and rewards a clear notch above its tier, and remember it
|
||||
|
||||
@@ -24,6 +24,10 @@ class Slot(StrEnum):
|
||||
WEAPON = "weapon"
|
||||
ARMOR = "armor"
|
||||
CONSUMABLE = "consumable"
|
||||
# v0.10 forge ore: a crafting MATERIAL carried in the satchel and spent at
|
||||
# the forge. It is never equipped, never quaffed (no atk/def/heal), and
|
||||
# never sold or bought — ore is earned in combat, not traded.
|
||||
MATERIAL = "material"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -63,12 +67,21 @@ class Player:
|
||||
gamble_day: int = 0
|
||||
# v0.7 "depth below" retention columns: how far the dungeon has been
|
||||
# plumbed (0 = never descended; N = cleared rung N, 1-indexed), the
|
||||
# comma-joined carried-potion satchel ('' = empty), and the enhancement
|
||||
# plus on whichever weapon/armour is CURRENTLY equipped in each slot.
|
||||
# carried satchel (see below), and the enhancement plus on whichever
|
||||
# weapon/armour is CURRENTLY equipped in each slot.
|
||||
deepest_rung: int = 0
|
||||
# v0.10 STACK-BASED satchel: comma-joined "id:qty" stacks ('' = empty),
|
||||
# e.g. "minor_potion:3,iron_ore:5". ``satchel_max`` caps DISTINCT stacks,
|
||||
# not total items; per-stack qty is unbounded. Replaces the v0.7 flat id
|
||||
# list. The "id:qty" wire format is owned by understone.engine.satchel
|
||||
# (decode_satchel/encode_satchel); every reader goes through that codec.
|
||||
satchel: str = ""
|
||||
weapon_plus: int = 0
|
||||
armor_plus: int = 0
|
||||
# v0.10 the Vault: gold banked at the inn. SAFE from ambush (the steal only
|
||||
# ever touches carried ``gold``) and SURVIVES the Wyrm-win legacy reset (a
|
||||
# small persistent reward across runs, like a win ★).
|
||||
banked: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -204,6 +217,15 @@ class Settings:
|
||||
forge_base_cost: int
|
||||
forge_max_plus: int
|
||||
rare_drop_item: str
|
||||
# v0.10 the ore-gated forge: the world's forge MATERIAL item id (validated
|
||||
# to slot=material), the ore each +1 step costs (need = (plus + 1) *
|
||||
# per_plus), and the two ore sources — a guaranteed drop on a won dungeon
|
||||
# rung and a chance of one ore on a won forest fight. Ore is combat-earned,
|
||||
# never purchasable; the forge spends gold AND ore.
|
||||
forge_ore_item: str
|
||||
forge_ore_per_plus: int
|
||||
ore_dungeon_drop: int
|
||||
ore_forest_chance: float
|
||||
# v0.8 "worlds without authors": the Watch's per-world CRT palette. One of
|
||||
# the names in WATCH_THEMES; defaults to "phosphor" (the original green), so
|
||||
# a pack that omits it looks exactly as the Vale always has.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The satchel wire codec — the one home for the ``"id:qty"`` stack encoding.
|
||||
|
||||
A player's satchel is stored as a single string: comma-joined ``id:qty`` stacks,
|
||||
e.g. ``"minor_potion:3,iron_ore:5"``; an empty string is an empty bag. This
|
||||
module is the SINGLE source of truth for that format. Three readers carried a
|
||||
byte-identical decode loop (the game façade, the Watch payload builder, and the
|
||||
balance simulator); they all delegate here so the format is described — and
|
||||
parsed — in exactly one place.
|
||||
|
||||
The codec is pure and stdlib-only: it knows the wire shape and nothing else.
|
||||
It does NOT collapse duplicate ids into one stack, resolve ids against a content
|
||||
pack, or enforce the distinct-stack cap — those are stack *semantics* the
|
||||
callers own. The codec only encodes and decodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def decode_satchel(s: str) -> list[tuple[str, int]]:
|
||||
"""Decode the ``"id:qty"`` satchel string into ordered ``(item_id, qty)`` stacks.
|
||||
|
||||
Splits on ``","`` and skips empty chunks (so an empty string, a leading or
|
||||
trailing comma, and a doubled comma all yield no spurious stack). Each chunk
|
||||
is partitioned on ``":"``:
|
||||
|
||||
* a chunk with no colon (a bare id) parses as quantity ``1`` — a colonless
|
||||
fragment is treated as a single item, never silently dropped;
|
||||
* a chunk whose quantity is present but not an integer, or is ``<= 0``, is
|
||||
skipped;
|
||||
* a chunk with an empty id is skipped.
|
||||
|
||||
Order is preserved (first-stowed first), which fixes which potion a heal tie
|
||||
resolves to. The codec collapses nothing — callers own stack semantics.
|
||||
"""
|
||||
stacks: list[tuple[str, int]] = []
|
||||
for chunk in s.split(","):
|
||||
if not chunk:
|
||||
continue
|
||||
item_id, sep, qty_str = chunk.partition(":")
|
||||
if not item_id:
|
||||
continue
|
||||
if not sep:
|
||||
# A bare id with no colon is a single item (defensive: never drop it).
|
||||
stacks.append((item_id, 1))
|
||||
continue
|
||||
try:
|
||||
qty = int(qty_str)
|
||||
except ValueError:
|
||||
continue
|
||||
if qty > 0:
|
||||
stacks.append((item_id, qty))
|
||||
return stacks
|
||||
|
||||
|
||||
def encode_satchel(stacks: list[tuple[str, int]]) -> str:
|
||||
"""Encode ``(item_id, qty)`` stacks back into the comma-joined ``"id:qty"`` string.
|
||||
|
||||
Any stack at quantity ``<= 0`` is dropped, so the encoding never emits
|
||||
``"id:0"``; this is the single home for the drop-at-empty rule, letting
|
||||
callers decrement freely and rely on a spent-to-zero stack falling away.
|
||||
"""
|
||||
return ",".join(f"{item_id}:{qty}" for item_id, qty in stacks if qty > 0)
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -130,10 +130,13 @@ DELVING DEEP (the reasons to come back)
|
||||
spawn reset. Play that beat big: the elixir burning down their throat at the
|
||||
edge of death. (A sleeping ambush victim never auto-quaffs — they are
|
||||
asleep.)
|
||||
* THE FORGE. The shop's forge spends gold to add a +1 edge to the equipped
|
||||
* THE FORGE — GOLD AND ORE. The shop's forge adds a +1 edge to the equipped
|
||||
weapon or armour ('forge' target="weapon"/"armour"), up to a cap, each tier
|
||||
dearer than the last — the late-game gold sink. Swapping or selling that
|
||||
piece loses the edge with it.
|
||||
dearer than the last. A step costs gold AND forge ore — a material the hero
|
||||
EARNS in combat, never buys: every cleared dungeon rung drops some, and a won
|
||||
forest fight sometimes turns up a little. So the forge is fed by descending,
|
||||
not just by a fat purse; a hero short of ore is told so. Swapping or selling
|
||||
a forged piece loses the edge with it.
|
||||
* RARE BEASTS. A few named beasts prowl the forest, surfacing seldom. Felling
|
||||
one is loud — a public Herald flash — and it always guards a draught that
|
||||
drops into the satchel (if there is room). Treat a rare kill as a small
|
||||
@@ -190,6 +193,15 @@ THE SOCIAL LAYER (rivals, mail, and dice)
|
||||
is capped per day. A big win is heralded; a quiet one is just a story you
|
||||
tell. Remind players the house has no mercy and the odds are even at best.
|
||||
|
||||
THE VAULT (banking coin at the inn)
|
||||
The inn keeps a strongbox. DEPOSIT (door_action action="deposit" amount=<gold>)
|
||||
moves coin from the hero's hand into the vault; WITHDRAW (action="withdraw"
|
||||
amount=<gold>) draws it back. Neither costs a turn. Two things make the vault
|
||||
matter: banked gold is SAFE FROM AMBUSH (a sleeping-robber lifts only what the
|
||||
victim carries), so banking before logging off is the way to protect a purse;
|
||||
and banked gold SURVIVES THE WYRM-WIN RESET — it is the one wealth a reborn
|
||||
hero keeps, alongside their ★. Suggest a wary player bank their winnings.
|
||||
|
||||
TOOL CHEAT-SHEET
|
||||
door_help This manual.
|
||||
door_join(player) Sign in (creates or resumes a character).
|
||||
@@ -198,7 +210,8 @@ TOOL CHEAT-SHEET
|
||||
door_move(player, ...) Walk the overworld (free). steps="NNEE" or
|
||||
heading="east" + distance=3 (max 8 per call).
|
||||
door_action(player, action) Context verb: fight, flee, ambush (a rival),
|
||||
rest, buy, sell, forge (a +1 edge), heal,
|
||||
rest, deposit/withdraw (the inn vault), buy,
|
||||
sell, forge (a +1 edge, gold + ore), heal,
|
||||
gamble (dice at the inn), descend (one rung),
|
||||
challenge (the Wyrm), post (a note), quaff (a
|
||||
carried potion), leave.
|
||||
@@ -375,9 +388,12 @@ def door_action(
|
||||
robbery in the spirit of the classic door-game player-kill (target=<name>, spends a turn).
|
||||
Inside a building: 'rest' (inn), 'buy'/'sell'/'forge' (shop), 'heal'
|
||||
(healer), or 'leave'. At the inn you may also 'gamble' a stake of gold at
|
||||
dice (amount=<gold>). Buying a potion now stows it in your satchel rather
|
||||
than drinking it; 'forge' (target="weapon"/"armour", at the shop) spends
|
||||
gold to add a +1 edge to your equipped gear, up to a cap. At the dungeon:
|
||||
dice (amount=<gold>), or bank coin in the vault with 'deposit'/'withdraw'
|
||||
(amount=<gold>) — banked gold is safe from ambush and survives a Wyrm-win
|
||||
reset. Buying a potion now stows it in your satchel rather than drinking it;
|
||||
'forge' (target="weapon"/"armour", at the shop) spends gold AND forge ore
|
||||
(won in the deep) to add a +1 edge to your equipped gear, up to a cap. At
|
||||
the dungeon:
|
||||
'descend' ONE rung of the deep — the next guardian past your deepest, one
|
||||
per turn — or 'challenge' the Wyrm Below, the endgame boss and the only way
|
||||
to win. Descending a rung advances your depth; reaching the floor opens the
|
||||
@@ -392,13 +408,15 @@ def door_action(
|
||||
|
||||
Args:
|
||||
player: The adventurer's name.
|
||||
action: The verb to attempt (fight, flee, ambush, rest, buy, sell,
|
||||
forge, heal, gamble, descend, challenge, post, quaff, leave).
|
||||
action: The verb to attempt (fight, flee, ambush, rest, deposit,
|
||||
withdraw, buy, sell, forge, heal, gamble, descend, challenge, post,
|
||||
quaff, leave).
|
||||
target: The other player's name for 'ambush'/'post', or the slot
|
||||
("weapon"/"armour") for 'forge'.
|
||||
item: For shop 'buy', the item id to purchase.
|
||||
text: For 'post', the note left for the target (<= 120 characters).
|
||||
amount: For inn 'gamble', the gold wagered on the dice.
|
||||
amount: For inn 'gamble', the gold wagered on the dice; for the vault
|
||||
'deposit'/'withdraw', the gold moved.
|
||||
"""
|
||||
blank = _guard_name(player)
|
||||
if blank is not None:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from understone.engine.models import Mode
|
||||
from understone.engine.satchel import decode_satchel
|
||||
from understone.screen import texture
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -96,6 +97,9 @@ def build_state_payload(game: Game) -> dict[str, object]:
|
||||
"hp": p.hp,
|
||||
"max_hp": p.max_hp,
|
||||
"mode": p.mode.value if isinstance(p.mode, Mode) else str(p.mode),
|
||||
"gold": p.gold,
|
||||
"banked": p.banked,
|
||||
"satchel": _satchel_entries(game, p.satchel),
|
||||
}
|
||||
for p in game.players.values()
|
||||
]
|
||||
@@ -119,6 +123,23 @@ def build_state_payload(game: Game) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _satchel_entries(game: Game, satchel: str) -> list[dict[str, object]]:
|
||||
"""Decode a player's ``"id:qty"`` satchel into ``[{"name", "qty"}, ...]``.
|
||||
|
||||
Decodes the bag through the shared
|
||||
:func:`~understone.engine.satchel.decode_satchel` codec, then resolves each
|
||||
stack's id to its display name via the world's item table; an id no longer in
|
||||
the pack (a save edited out from under it) falls back to the raw id, so the
|
||||
lobby TV never shows a blank entry. The Watch is read-only, so it only
|
||||
decodes — the name-resolution is the only work that lives here.
|
||||
"""
|
||||
entries: list[dict[str, object]] = []
|
||||
for item_id, qty in decode_satchel(satchel):
|
||||
item = game.world.item_by_id(item_id)
|
||||
entries.append({"name": item.name if item is not None else item_id, "qty": qty})
|
||||
return entries
|
||||
|
||||
|
||||
def _recent_events(game: Game) -> list[Event]:
|
||||
"""Return the last :data:`_HERALD_LIMIT` resident PUBLIC events, oldest-first.
|
||||
|
||||
@@ -288,6 +309,7 @@ _WATCH_HTML_TEMPLATE = """\
|
||||
ul { margin: 0; padding: 0; list-style: none; }
|
||||
li { padding: 2px 0; }
|
||||
.muted { color: var(--phosphor-dim); }
|
||||
.subline { font-size: 12px; padding-left: 2px; }
|
||||
.adv-name { color: var(--amber); }
|
||||
.stars { color: var(--amber); letter-spacing: 1px; }
|
||||
.feed li { border-bottom: 1px dotted var(--edge); padding: 4px 0; }
|
||||
@@ -554,10 +576,33 @@ _WATCH_HTML_TEMPLATE = """\
|
||||
rest.className = "muted";
|
||||
rest.textContent = " Lv" + p.level + " HP " + p.hp + "/" + p.max_hp;
|
||||
li.appendChild(rest);
|
||||
// A dim sub-line: gold on hand and (if any) gold in the vault. The whole
|
||||
// shared world is on the lobby TV, so every hero's purse is public here.
|
||||
var gold = document.createElement("div");
|
||||
gold.className = "muted subline";
|
||||
var goldText = (p.gold || 0) + "g";
|
||||
if (p.banked) { goldText += " +" + p.banked + " vault"; }
|
||||
gold.textContent = goldText;
|
||||
li.appendChild(gold);
|
||||
// A second dim sub-line: the satchel stacks ("Name ×qty"), or empty.
|
||||
var sat = document.createElement("div");
|
||||
sat.className = "muted subline";
|
||||
sat.textContent = satchelText(p.satchel || []);
|
||||
li.appendChild(sat);
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the satchel stacks as a compact dot-joined line, or an empty note.
|
||||
function satchelText(stacks) {
|
||||
if (!stacks.length) { return "satchel empty"; }
|
||||
var parts = [];
|
||||
for (var i = 0; i < stacks.length; i++) {
|
||||
parts.push(stacks[i].name + " \\u00d7" + stacks[i].qty);
|
||||
}
|
||||
return parts.join(" \\u00b7 ");
|
||||
}
|
||||
|
||||
function renderHall(hall) {
|
||||
var list = document.getElementById("hall");
|
||||
while (list.firstChild) { list.removeChild(list.firstChild); }
|
||||
|
||||
@@ -75,5 +75,11 @@
|
||||
"slot": "consumable",
|
||||
"heal": 70,
|
||||
"price": 60
|
||||
},
|
||||
{
|
||||
"id": "iron_ore",
|
||||
"name": "Iron Ore",
|
||||
"slot": "material",
|
||||
"price": 0
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
"name": "The Sleeping Drake",
|
||||
"glyph": "⌂",
|
||||
"color": "inn",
|
||||
"actions": ["rest", "gamble", "leave"],
|
||||
"actions": ["rest", "deposit", "withdraw", "gamble", "leave"],
|
||||
"flavor": [
|
||||
"Lamplight pools on worn oak tables.",
|
||||
"The innkeeper nods toward the hearth.",
|
||||
"A night's rest restores you fully.",
|
||||
"An iron strongbox by the bar keeps coin safe from sleeping-robbers.",
|
||||
"In the corner, a dice cup waits for a wager."
|
||||
]
|
||||
},
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"forge_base_cost": 60,
|
||||
"forge_max_plus": 3,
|
||||
"rare_drop_item": "greater_potion",
|
||||
"forge_ore_item": "iron_ore",
|
||||
"forge_ore_per_plus": 1,
|
||||
"ore_dungeon_drop": 2,
|
||||
"ore_forest_chance": 0.2,
|
||||
"watch_theme": "phosphor"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ SETTINGS_BANDS: dict[str, tuple[int, int | None]] = {
|
||||
"satchel_max": (1, 10),
|
||||
"forge_base_cost": (1, 10000),
|
||||
"forge_max_plus": (0, 10),
|
||||
# v0.10 the ore-gated forge: ore per +1 step, and the guaranteed ore drop on
|
||||
# a won dungeon rung. (forge_ore_item is a cross-ref, ore_forest_chance is a
|
||||
# float — both validated below, outside this int-band loop.)
|
||||
"forge_ore_per_plus": (0, 10),
|
||||
"ore_dungeon_drop": (0, 20),
|
||||
}
|
||||
|
||||
# Per-kind amount bands for the overworld event table (inclusive).
|
||||
@@ -617,6 +622,8 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons
|
||||
dungeon_tiers = _decode_dungeon_tiers(spec, monsters)
|
||||
boss_monster = _decode_boss_monster(spec, monsters)
|
||||
rare_drop_item = _decode_rare_drop_item(spec, items)
|
||||
forge_ore_item = _decode_forge_ore_item(spec, items)
|
||||
ore_forest_chance = _decode_ore_forest_chance(spec)
|
||||
watch_theme = _decode_watch_theme(spec)
|
||||
|
||||
return Settings(
|
||||
@@ -647,6 +654,10 @@ def _decode_settings(raw: dict[str, Any], items: list[Item], monsters: list[Mons
|
||||
forge_base_cost=values["forge_base_cost"],
|
||||
forge_max_plus=values["forge_max_plus"],
|
||||
rare_drop_item=rare_drop_item,
|
||||
forge_ore_item=forge_ore_item,
|
||||
forge_ore_per_plus=values["forge_ore_per_plus"],
|
||||
ore_dungeon_drop=values["ore_dungeon_drop"],
|
||||
ore_forest_chance=ore_forest_chance,
|
||||
watch_theme=watch_theme,
|
||||
)
|
||||
|
||||
@@ -694,6 +705,44 @@ def _decode_rare_drop_item(spec: dict[str, Any], items: list[Item]) -> str:
|
||||
return drop_id
|
||||
|
||||
|
||||
def _decode_forge_ore_item(spec: dict[str, Any], items: list[Item]) -> str:
|
||||
"""Resolve and validate the world's forge ore — the material the forge spends.
|
||||
|
||||
The id must name an item in the pack AND that item must be a ``material``
|
||||
(it is carried in the satchel and spent at the forge, never equipped or
|
||||
quaffed, so a weapon/armour/consumable id would be incoherent). Mirrors the
|
||||
``rare_drop_item`` check but pins the slot to :attr:`Slot.MATERIAL`.
|
||||
"""
|
||||
ore_id = str(_require(spec, "forge_ore_item", "world.json settings"))
|
||||
by_id = {it.item_id: it for it in items}
|
||||
item = by_id.get(ore_id)
|
||||
if item is None:
|
||||
raise WorldLoadError(
|
||||
f"world.json settings.forge_ore_item = {ore_id!r} is not a known item id"
|
||||
)
|
||||
if item.slot is not Slot.MATERIAL:
|
||||
raise WorldLoadError(
|
||||
f"world.json settings.forge_ore_item = {ore_id!r} must be a material item, "
|
||||
f"not {item.slot.value!r}"
|
||||
)
|
||||
return ore_id
|
||||
|
||||
|
||||
def _decode_ore_forest_chance(spec: dict[str, Any]) -> float:
|
||||
"""Resolve and validate the per-win forest ore chance (a 0.0..1.0 float).
|
||||
|
||||
The chance a won forest fight yields one ore. A float, so it is validated
|
||||
here rather than through the integer :data:`SETTINGS_BANDS` loop, mirroring
|
||||
the ``encounter_rate`` probability check in terrain.
|
||||
"""
|
||||
chance = float(_require(spec, "ore_forest_chance", "world.json settings"))
|
||||
if not 0.0 <= chance <= 1.0:
|
||||
raise WorldLoadError(
|
||||
f"world.json settings.ore_forest_chance = {chance} is out of band (0.0..1.0)"
|
||||
)
|
||||
return chance
|
||||
|
||||
|
||||
def _decode_watch_theme(spec: dict[str, Any]) -> str:
|
||||
"""Resolve and validate the Watch CRT palette name (optional, defaulted).
|
||||
|
||||
|
||||
@@ -75,5 +75,11 @@
|
||||
"slot": "consumable",
|
||||
"heal": 70,
|
||||
"price": 60
|
||||
},
|
||||
{
|
||||
"id": "slag_iron",
|
||||
"name": "Slag-Iron",
|
||||
"slot": "material",
|
||||
"price": 0
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
"name": "The Forge-Rest",
|
||||
"glyph": "⌂",
|
||||
"color": "inn",
|
||||
"actions": ["rest", "gamble", "leave"],
|
||||
"actions": ["rest", "deposit", "withdraw", "gamble", "leave"],
|
||||
"flavor": [
|
||||
"Heat-bricked walls hold back the ashfall outside.",
|
||||
"The hearthwright stokes a banked forge and nods you toward a cot.",
|
||||
"A night beside the coals restores you fully.",
|
||||
"A slag-iron strongbox by the hearth keeps coin safe from sleeping-robbers.",
|
||||
"In the corner, a cup of knucklebones rattles for a wager."
|
||||
]
|
||||
},
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"forge_base_cost": 60,
|
||||
"forge_max_plus": 3,
|
||||
"rare_drop_item": "cooling_draught",
|
||||
"forge_ore_item": "slag_iron",
|
||||
"forge_ore_per_plus": 1,
|
||||
"ore_dungeon_drop": 2,
|
||||
"ore_forest_chance": 0.2,
|
||||
"watch_theme": "ember"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user