Files
turnstone/examples/door-game/understone/sim.py
T
Patrick Buckley 393a6fc2b2 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.
2026-06-13 04:40:40 -07:00

1002 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The balance instrument — a greedy bot that PLAYS a world to measure it.
This is a TUNING PROBE, not an optimiser. It drives a deliberately simple,
greedy heuristic adventurer through the *real* :class:`~understone.game.Game`
façade — the same ``join`` / ``move`` / ``action`` methods the MCP tools call —
over a fresh in-memory store, a seeded :class:`~understone.engine.rng.GameRNG`,
and an injected clock. Because it plays through the actual façade, a sim run is
also a fierce end-to-end integration test of the whole stack: every system the
report reflects (movement, the zone-banded forest, the rung ladder, the forge
and satchel, the Wyrm endgame) is exercised by the real engine, never mocked.
The bot is a yardstick, not a player to admire. Its policy is the obvious greedy
one — spend each daily turn on the single best-looking action, navigate for free
between town and the wilds, keep itself geared and potioned — so the resulting
:class:`BalanceReport` answers "is this world *shaped* right for a competent but
unclever hero?": does a run make steady progress, is the fight/descend mix sane,
and — the load-bearing question — is the world *winnable*, i.e. can a greedy bot
actually slay the Wyrm in a reasonable number of days?
The module is PURE: it imports the engine, the game façade, and the loader only,
and never touches ``mcp``, ``starlette``, or the network.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
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
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
from understone.engine.world import World
# The bot's adventurer name in every sim (a fixed handle keeps the run readable).
_BOT_NAME = "Probe"
# A run begins at this fixed UTC instant; the clock advances exactly one day per
# simulated day so the daily turn budget resets cleanly between sim-days. Midday
# avoids the Watch day/night edges (irrelevant here, but keeps the instant tidy).
_SIM_START = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
# Heuristic margins (all fractions of max_hp). The bot rests/heals below
# ``_REST_BELOW``; it only commits to a fight or a rung it expects to END at or
# above ``_FIGHT_MARGIN`` / ``_DESCEND_MARGIN`` of full health, so it spends
# turns on bouts it can likely win rather than feeding the death-save.
_REST_BELOW = 0.5
_FIGHT_MARGIN = 0.30
_DESCEND_MARGIN = 0.45
# The Wyrm is the climax and a legacy reset rides on it, so the bot will not
# stake a challenge until a pessimistic full-health bout clears this much hp —
# it keeps grinding the deep for levels, gear, and forge edges until it can
# actually win, rather than feeding doomed challenges to the boss.
_WYRM_MARGIN = 0.20
# "Flush" gold floor for opportunistic spending (gear, forge, potions): the bot
# keeps at least this much in reserve so a shopping spree never strands it unable
# to rest. A small, world-agnostic buffer expressed against a day's rest cost.
_RESERVE_RESTS = 3
# Hard cap on free navigation moves toward a single waypoint, so a bot that can
# never thread a monster-thick wood (or a malformed map) ends the day instead of
# looping forever. Generous — a clear path is a handful of 8-cell hops.
_GOTO_MAX_MOVES = 200
@dataclass(frozen=True, slots=True)
class BalanceReport:
"""The measured outcome of one greedy bot run over a world.
Every field is a yardstick for tuning, not a score. ``deaths_survived`` are
the bouts the satchel's death-save snatched back; ``deaths_taken`` the
genuine spawn bounces. ``realized_fight_share`` is the fraction of
turn-spending actions that were forest fights (vs descents and the Wyrm
challenge) — the run's actual analogue of the pack's authored fight-weight.
``ending_satchel`` is the potions still carried at the final bell.
``final_level`` is the level at the FINAL bell, not the peak reached: a Wyrm
kill reincarnates the hero with a legacy reset (``level`` → 1), so a winning
run can report a ``final_level`` as low as 1. Read it alongside
``wyrm_killed`` — a low level on a won run means the hero is mid-reclimb, not
that the run stalled. Averaging ``final_level`` across a sweep that mixes wins
and losses conflates the two; the renders pair the two fields for this reason.
"""
days: int
seed: int
final_level: int
total_gold_earned: int
deaths_survived: int
deaths_taken: int
rungs_cleared: int
wyrm_killed: bool
day_of_first_wyrm_kill: int | None
fights_fought: int
realized_fight_share: float
ending_satchel: tuple[str, ...]
class _Clock:
"""A mutable injected clock: reports a fixed instant, steppable by a day."""
def __init__(self, start: datetime) -> None:
self._now = start
def __call__(self) -> datetime:
return self._now
def advance_day(self) -> None:
"""Move the clock forward one UTC day (the sim-day boundary)."""
self._now += timedelta(days=1)
def simulate(pack_dir: Path, days: int, seed: int) -> BalanceReport:
"""Play a greedy bot through the world at *pack_dir* and return its report.
Builds a real :class:`Game` over a fresh in-memory store with the seeded RNG
and the steppable clock, joins the bot, then runs *days* sim-days: each day
the bot spends its turn budget on the best available action (with free
navigation between), and the clock advances one day so turns reset. The
returned :class:`BalanceReport` is fully determined by ``(pack_dir, days,
seed)`` — same inputs, identical report.
"""
world = load_world(pack_dir)
clock = _Clock(_SIM_START)
store = Store(":memory:")
game = Game(world, store, clock=clock, rng=GameRNG(seed=seed))
bot = _Bot(game, world, clock)
bot.run(days)
return bot.report(days=days, seed=seed)
class _Bot:
"""The greedy heuristic adventurer that drives the real game façade."""
def __init__(self, game: Game, world: World, clock: _Clock) -> None:
self.game = game
self.world = world
self.clock = clock
self.name = _BOT_NAME
self._walkable = _reachable(world)
self._waypoints = _named_waypoints(world)
self._hunt_spots = _zone_hunt_spots(world, self._walkable)
# Accumulators the report is built from.
self.total_gold_earned = 0
self.deaths_survived = 0
self.deaths_taken = 0
self.fights_fought = 0
self.descents = 0
self.challenges = 0
self.rungs_cleared = 0
self.wins = 0
self.day_of_first_wyrm_kill: int | None = None
# -- the run loop ----------------------------------------------------
def run(self, days: int) -> None:
"""Play *days* sim-days, advancing the clock one day between each."""
self.game.join(self.name)
for _day in range(days):
self._play_day()
self.clock.advance_day()
def _play_day(self) -> None:
"""Spend the day's turn budget on the best action the bot can find.
Movement and town errands (rest/heal/buy/forge) are free, so a single
day may interleave many of them around the few turn-spending bouts. The
loop ends when the bot is out of turns or can find nothing useful to do.
"""
# Roll the daily budget eagerly so the loop gate below sees the fresh
# turn count. The game does this lazily on the first action; the bot's
# gate reads turns_left BEFORE acting, so it must roll first itself. The
# roll is idempotent — the next façade action re-rolls the same ordinal
# and persists it — so this never double-grants.
turns.ensure_day(self._player(), self.clock, self._turn_budget())
# Iteration cap: a pure runaway guard, never reached in normal play. Each
# loop either spends a turn or does a free errand (recover/buy/forge),
# and the free errands per day are bounded (gear is finite, forge and
# satchel cap out), so the real per-day count is well under this — a
# generous multiple of the turn budget that a healthy day never nears.
guard = 0
max_steps = self._turn_budget() * 8 + 32
while self._turns_left() > 0 and guard < max_steps:
guard += 1
if not self._take_best_action():
break
def _take_best_action(self) -> bool:
"""Do the single best thing right now; return False when nothing helps.
Priority order (the greedy policy): survive (rest/heal when hurt), keep
the satchel and the gear strong while flush, then spend a turn on the
best bout — challenge the Wyrm if ready, else descend a rung the bot can
likely clear, else hunt the best survivable forest zone.
"""
player = self._player()
# 1. Survive: mend before risking a turn, if a bed/shrine is affordable.
if player.hp < player.max_hp * _REST_BELOW and self._recover():
return True
# 2. Spend down a flush purse on lasting advantages (free, no turn).
if self._upgrade_gear() or self._stock_satchel() or self._forge_edge():
return True
# 3. The Wyrm: the win condition, the moment it is reachable.
if self._wyrm_ready():
self._heal_full_if_possible()
return self._challenge()
# 4. Descend a rung the bot expects to clear (mended first).
if self._should_descend():
self._heal_full_if_possible()
return self._descend()
# 5. Otherwise hunt the best survivable forest for XP and gold.
return self._hunt()
# -- decisions: recovery & spending ----------------------------------
def _recover(self) -> bool:
"""Rest at the inn (preferred) or heal at the shrine; True if mended."""
settings = self.world.settings
player = self._player()
if "inn" in self._waypoints and player.gold >= settings.rest_cost:
return self._errand("inn", "rest")
free_heal = settings.heal_cost_per_hp == 0
if "healer" in self._waypoints and (
free_heal or player.gold >= settings.heal_cost_per_hp * 4
):
return self._errand("healer", "heal")
return False
def _upgrade_gear(self) -> bool:
"""Buy the best affordable weapon/armour upgrade if flush; True if bought."""
if not self._shop_offers("buy"):
return False
player = self._player()
weapon = self._best_upgrade(Slot.WEAPON, player.weapon_id)
armor = self._best_upgrade(Slot.ARMOR, player.armor_id)
target = self._dearer(weapon, armor)
if target is None or not self._can_afford(target.price):
return False
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.
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
if self._potions_carried() >= self.world.settings.satchel_max:
return False
potion = self._best_affordable_potion()
if potion is None:
return False
return self._errand("shop", "buy", item=potion.item_id)
def _forge_edge(self) -> bool:
"""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), 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
settings = self.world.settings
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"),
):
if current >= settings.forge_max_plus:
continue
cost = settings.forge_base_cost * (current + 1)
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
# -- decisions: the turn-spending bouts ------------------------------
def _wyrm_ready(self) -> bool:
"""True when the bot can BEAT the Wyrm: both gates plus a winnable bout.
Meeting the engine's gates (level floor and full depth) only OPENS the
challenge; the bot adds its own readiness test — a pessimistic
full-health bout against the boss clearing :data:`_WYRM_MARGIN` — so it
challenges when it can win, not the instant it is allowed to. Until then
the deep-zone grind keeps raising its level, gear, and forge edges.
"""
settings = self.world.settings
player = self._player()
if "dungeon" not in self._waypoints:
return False
if player.level < settings.wyrm_min_level:
return False
if player.deepest_rung < len(settings.dungeon_tiers):
return False
boss = self.world.monster_by_id(settings.boss_monster)
if boss is None:
return False
return _survivable(
player.max_hp, player.max_hp, player.atk, player.def_, boss, _WYRM_MARGIN
)
def _challenge(self) -> bool:
"""Enter the dungeon and challenge the Wyrm (spends a turn).
Returns False if the dungeon menu can't be entered (malformed map), so
the day loop ends rather than spinning on a no-op overworld challenge.
"""
if not self._enter("dungeon"):
return False
wins_before = self._player().wins
out = self._act("challenge")
self.challenges += 1
self._note_outcome(out, wins_before)
self._leave_if_in_menu()
return True
def _should_descend(self) -> bool:
"""True when a dungeon exists, a rung remains, and the bot can likely win.
Looks at the SPECIFIC next-rung guardian (``band[0]`` of the next tier,
the engine's fixed-foe rung) and only commits if a full-health bout
projects to end above the descend margin — so the bot pushes the deep
when geared for it rather than throwing turns at a wall.
"""
settings = self.world.settings
player = self._player()
if "dungeon" not in self._waypoints:
return False
if player.deepest_rung >= len(settings.dungeon_tiers):
return False
guardian = self._rung_guardian(player.deepest_rung)
if guardian is None:
return False
# Judged from FULL health (the bot heals before descending), so the gate
# asks "can I clear this rung fresh?" not "from my current scratches?".
return _survivable(
player.max_hp, player.max_hp, player.atk, player.def_, guardian, _DESCEND_MARGIN
)
def _descend(self) -> bool:
"""Enter the dungeon and descend one rung (spends a turn).
Returns False if the dungeon menu can't be entered, so a malformed map
ends the day instead of spinning. ``advanced`` (a cleared rung) suppresses
the bounce check, since a cleared rung climbs out rather than bouncing.
"""
if not self._enter("dungeon"):
return False
before = self._player().deepest_rung
wins_before = self._player().wins
out = self._act("descend")
self.descents += 1
after = self._player().deepest_rung
self.rungs_cleared = max(self.rungs_cleared, after)
self._note_outcome(out, wins_before, advanced=after > before)
self._leave_if_in_menu()
return True
def _hunt(self) -> bool:
"""Fight in the best survivable forest zone; True if a bout was fought.
Returns False when there is no reachable zone the bot can survive, OR
when the walk to the spot ended in a location MENU (a door swallowed the
navigation) — in either case the day yields the loop's turn rather than
burning it. A genuine bout always spends a turn, so a True return makes
real progress toward the daily cap.
"""
spot = self._best_hunt_spot()
if spot is None:
return False
self._goto_xy(spot)
if self._player().mode is not Mode.TILE:
# A door swallowed the walk; step back out and try once more.
self._leave_if_in_menu()
self._goto_xy(spot)
# Authoritative pre-check: a "fight" issued from a MENU is REJECTED by the
# engine without spending a turn (it is not a tile action), so counting it
# as a fought bout would over-count and let the day loop spin to its cap.
# If still not on open ground, yield the turn — leave the menu and bail.
if self._player().mode is not Mode.TILE:
self._leave_if_in_menu()
return False
wins_before = self._player().wins
out = self._act("fight")
if "nothing stirs to fight here" in out:
# Standing in TILE mode but outside any zone (navigation fell short):
# the engine spent no turn, so this is not a fought bout. Give up for
# now rather than re-rolling the same dry cell forever.
return False
self.fights_fought += 1
self._note_outcome(out, wins_before)
return True
# -- outcome bookkeeping ---------------------------------------------
def _note_outcome(self, out: str, wins_before: int, *, advanced: bool = False) -> None:
"""Fold one bout's result into the accumulators from observable state.
A Wyrm kill shows as ``wins`` ticking up (the legacy reset bumps it); a
death-save shows as the engine's death-save line in *out*; a genuine
bounce shows as the hero standing at the spawn at 1 HP without either of
the above. ``advanced`` (descend only) suppresses the bounce check on a
cleared rung, which never bounces. Gold earned is tracked separately,
per call, in :meth:`_act`.
"""
player = self._player()
if player.wins > wins_before:
self.wins += player.wins - wins_before
if self.day_of_first_wyrm_kill is None:
self.day_of_first_wyrm_kill = self._current_day
return
if Game._DEATH_SAVE_LINE in out:
self.deaths_survived += 1
return
if not advanced and (player.x, player.y) == self.world.spawn and player.hp <= 1:
self.deaths_taken += 1
# -- low-level game driving ------------------------------------------
def _act(self, action: str, *, target: str = "", item: str = "") -> str:
"""Call ``game.action`` and accrue any positive gold delta as earnings.
Earnings are POSITIVE inflows only (a fight reward, a forest gold find,
a dice win), so spending at the shop/inn/forge never counts against the
total. The before/after read brackets the single façade call, so every
gold source the engine applies is captured without enumerating them.
"""
before = self._player().gold
out = self.game.action(self.name, action, target, item)
delta = self._player().gold - before
if delta > 0:
self.total_gold_earned += delta
return out
def _goto(self, waypoint: str) -> None:
"""Navigate to a named waypoint (inn/shop/healer/dungeon door cell)."""
target = self._waypoints.get(waypoint)
if target is not None:
self._goto_xy(target)
def _enter(self, waypoint: str) -> bool:
"""Ensure the bot stands INSIDE *waypoint*'s menu; return success.
A location's menu opens only by MOVING onto its door — standing on the
door cell in TILE mode (e.g. right after leaving) does not reopen it. So
this walks to a cell ADJACENT to the door and steps in, guaranteeing the
``entered_location`` flip to MENU mode. Already in the right menu, it is a
no-op. This is the entry every town errand uses, so a buy/rest/forge is
never attempted from the overworld (which would silently no-op and spin).
"""
door = self._waypoints.get(waypoint)
if door is None:
return False
player = self._player()
if player.mode is Mode.MENU and player.at_location == waypoint:
return True
self._leave_if_in_menu()
approach = _adjacent_open(self.world, self._walkable, door)
if approach is None:
return False
self._goto_xy(approach)
if (self._player().x, self._player().y) != approach:
return False
step = _step_between(approach, door)
if step is None:
return False
self.game.move(self.name, step, "", 0)
p = self._player()
return p.mode is Mode.MENU and p.at_location == waypoint
def _errand(self, waypoint: str, action: str, *, target: str = "", item: str = "") -> bool:
"""Enter *waypoint*'s menu and run one free town *action*; True if done.
Returns False when the menu can't be entered (a malformed map), so the
caller treats the errand as "couldn't help" rather than spinning on a
no-op the way a TILE-mode buy would. The menu is left afterwards so the
next decision starts cleanly on the overworld.
"""
if not self._enter(waypoint):
return False
self._act(action, target=target, item=item)
self._leave_if_in_menu()
return True
def _goto_xy(self, goal: tuple[int, int]) -> None:
"""Walk the bot to *goal* over free moves, threading incidental foes.
Steps the BFS path in 8-cell hops, re-planning from the actual position
after each hop because a walk can stop early (a wall, a door, or a
wandering monster). Incidental forest encounters need no handling: a
blocked move simply makes no progress that hop, and the next hop re-rolls
from the new cell, so the bot threads the wood without spending a turn.
A move-count cap prevents an unthreadable map from looping forever.
"""
moves = 0
while moves < _GOTO_MAX_MOVES:
player = self._player()
if player.mode is Mode.MENU:
# Already at a door; if it is the goal door, we're there.
loc = self.world.location_at(*goal)
if loc is not None and (player.x, player.y) == goal:
return
self._leave_if_in_menu()
player = self._player()
if (player.x, player.y) == goal:
return
path = _bfs_step_path(self.world, self._walkable, (player.x, player.y), goal)
if not path:
return
steps = "".join(path[:8])
self.game.move(self.name, steps, "", 0)
moves += 1
def _leave_if_in_menu(self) -> None:
"""Step back onto the overworld if the bot is inside a location menu."""
if self._player().mode is Mode.MENU:
self.game.action(self.name, "leave", "", "")
def _heal_full_if_possible(self) -> None:
"""Rest/heal to full before a marquee bout, if at all affordable."""
if self._player().hp < self._player().max_hp:
self._recover()
# -- heuristics over world content -----------------------------------
def _best_hunt_spot(self) -> tuple[int, int] | None:
"""Pick the highest-tier forest zone whose toughest foe the bot survives.
Escalates the bot from the starter wood to deeper zones as its gear and
level grow: it scans zones high tier first and returns the first whose
toughest COMMON foe a full-health bout clears above the fight margin.
When nothing yet qualifies (a fresh, under-geared bot), it falls back to
the LOWEST-tier zone — the starter wood — so the bot always has the
gentlest available ground to grind on rather than throwing itself at the
deep. The death-save covers the occasional unlucky bout there.
"""
player = self._player()
if not self._hunt_spots:
return None
ranked = sorted(self._hunt_spots, key=lambda zs: zs[0], reverse=True)
for _tier_hi, spot, toughest in ranked:
if toughest is None:
continue
# Full-health yardstick: the bot heals below _REST_BELOW, so "can I
# win this zone's toughest common foe fresh?" is the right question.
if _survivable(
player.max_hp, player.max_hp, player.atk, player.def_, toughest, _FIGHT_MARGIN
):
return spot
# Nothing clears the margin yet: grind the gentlest (lowest-tier) zone.
return ranked[-1][1]
def _rung_guardian(self, rung_index: int) -> Monster | None:
"""Return the fixed guardian of the next rung (``band[0]`` of its tier)."""
tiers = self.world.settings.dungeon_tiers
if not 0 <= rung_index < len(tiers):
return None
band = self.world.monsters_for_tier_band(tiers[rung_index], tiers[rung_index])
return band[0] if band else None
def _best_upgrade(self, slot: Slot, equipped_id: str) -> Item | None:
"""Return the best purchasable *slot* item that beats the equipped one.
Compares by the slot's base combat bonus (weapon→atk, armour→def): the
dearest shop item whose bonus exceeds the equipped item's. A None result
means nothing in the shop improves on what the bot wears.
"""
equipped = self.world.item_by_id(equipped_id)
equipped_bonus = self._slot_stat(equipped, slot) if equipped else 0
best: Item | None = None
for item in self.world.items:
if item.slot is not slot or item.price <= 0:
continue
if self._slot_stat(item, slot) <= equipped_bonus:
continue
if best is None or item.price > best.price:
best = item
return best
def _best_affordable_potion(self) -> Item | None:
"""Return the strongest-heal consumable the bot can currently afford."""
best: Item | None = None
for item in self.world.items:
if item.slot is not Slot.CONSUMABLE or item.price <= 0:
continue
if not self._can_afford(item.price):
continue
if best is None or item.heal > best.heal:
best = item
return best
def _shop_offers(self, verb: str) -> bool:
"""True when a shop exists AND its menu actually exposes *verb*.
The bot drives an arbitrary authored pack, whose shop need not list every
verb the Vale's does: a pack may sell wares but offer no forge, say. The
engine rejects a verb absent from a location's ``actions`` WITHOUT
spending a turn or coin, and :meth:`_errand` cannot tell that no-op from a
real one — so it would report success and the day loop would spin. Gating
the shop errands on the verb being genuinely on offer closes that spin.
"""
if "shop" not in self._waypoints:
return False
loc = self.world.location_by_key("shop")
return loc is not None and verb in loc.actions
def _gear_is_best(self) -> bool:
"""True when both equipped slots are already the shop's strongest."""
player = self._player()
return (
self._best_upgrade(Slot.WEAPON, player.weapon_id) is None
and self._best_upgrade(Slot.ARMOR, player.armor_id) is None
)
def _can_afford(self, price: int) -> bool:
"""True when paying *price* still leaves the rest-cost reserve intact."""
reserve = self.world.settings.rest_cost * _RESERVE_RESTS
return self._player().gold - price >= reserve
@staticmethod
def _slot_stat(item: Item, slot: Slot) -> int:
return item.atk if slot is Slot.WEAPON else item.def_
@staticmethod
def _dearer(a: Item | None, b: Item | None) -> Item | None:
"""Return whichever upgrade is the dearer (a rough 'bigger jump') pick."""
if a is None:
return b
if b is None:
return a
return a if a.price >= b.price else b
# -- report ----------------------------------------------------------
def report(self, *, days: int, seed: int) -> BalanceReport:
"""Freeze the run's accumulators into a :class:`BalanceReport`."""
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(f"{item_id}×{qty}" for item_id, qty in self._satchel_stacks(player.satchel))
return BalanceReport(
days=days,
seed=seed,
final_level=player.level,
total_gold_earned=self.total_gold_earned,
deaths_survived=self.deaths_survived,
deaths_taken=self.deaths_taken,
rungs_cleared=self.rungs_cleared,
wyrm_killed=self.wins > 0,
day_of_first_wyrm_kill=self.day_of_first_wyrm_kill,
fights_fought=self.fights_fought,
realized_fight_share=fight_share,
ending_satchel=satchel,
)
# -- tiny accessors --------------------------------------------------
@property
def _current_day(self) -> int:
"""The 1-indexed sim-day the clock currently sits on."""
return (self.clock().date() - _SIM_START.date()).days + 1
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
def _turn_budget(self) -> int:
return self.world.settings.daily_turns
# ---------------------------------------------------------------------------
# Combat & navigation helpers (pure functions over world content)
# ---------------------------------------------------------------------------
def _survivable(
cur_hp: int, max_hp: int, atk: int, def_: int, monster: Monster, margin: float
) -> bool:
"""Project whether a bout from *cur_hp* against *monster* clears *margin*.
A PESSIMISTIC estimate: the engine jitters each blow by ``randint(-1, 2)``
(:func:`understone.engine.combat._swing`), so this assumes the player's blows
land at the low end (``-1``) and the monster's at the high end (``+2``),
player striking first. The surviving hp is expressed as a fraction of
``max_hp`` and compared to *margin*. Because the real bout is usually kinder
AND the satchel death-save backstops a bad one, a margin-clearing bout is a
safe turn to spend; the gate errs toward over-preparing, which is what a
careful greedy probe should do.
"""
player_dmg = max(1, (atk - 1) - monster.def_)
monster_dmg = max(1, (monster.atk + 2) - def_)
rounds_to_kill = -(-monster.hp // player_dmg) # ceil division
# The player strikes first, so they take one fewer hit than rounds-to-kill.
hits_taken = rounds_to_kill - 1
remaining = cur_hp - hits_taken * monster_dmg
return remaining >= max_hp * margin
def _reachable(world: World) -> set[tuple[int, int]]:
"""Return every walkable cell reachable from the spawn (a BFS flood).
Computed once per run so navigation never re-floods. Location doors count as
walkable, so the town buildings and the dungeon mouth are in the set.
"""
seen = {world.spawn}
frontier: deque[tuple[int, int]] = deque([world.spawn])
while frontier:
x, y = frontier.popleft()
for dx, dy in ((0, -1), (0, 1), (1, 0), (-1, 0)):
nxt = (x + dx, y + dy)
if nxt not in seen and world.is_walkable(*nxt):
seen.add(nxt)
frontier.append(nxt)
return seen
def _bfs_step_path(
world: World,
walkable: set[tuple[int, int]],
start: tuple[int, int],
goal: tuple[int, int],
) -> list[str]:
"""Return cardinal steps (N/S/E/W) along a shortest walkable path to *goal*.
A plain breadth-first search over the precomputed *walkable* set; returns the
step letters the game's ``move`` accepts, or an empty list when *goal* is
unreachable (it never is, for a bundled world's town and dungeon, but the
bot tolerates a malformed pack gracefully). The goal cell itself need not be
in *walkable* beyond being a location door, which the flood already included.
"""
if start == goal:
return []
came_from: dict[tuple[int, int], tuple[tuple[int, int], str]] = {}
frontier: deque[tuple[int, int]] = deque([start])
seen = {start}
deltas = (((0, -1), "N"), ((0, 1), "S"), ((1, 0), "E"), ((-1, 0), "W"))
while frontier:
cur = frontier.popleft()
if cur == goal:
return _reconstruct(came_from, start, goal)
cx, cy = cur
for (dx, dy), letter in deltas:
nxt = (cx + dx, cy + dy)
if nxt in seen or nxt not in walkable:
continue
seen.add(nxt)
came_from[nxt] = (cur, letter)
frontier.append(nxt)
return []
def _reconstruct(
came_from: dict[tuple[int, int], tuple[tuple[int, int], str]],
start: tuple[int, int],
goal: tuple[int, int],
) -> list[str]:
"""Walk the BFS parent links back from *goal* to *start* into step letters."""
steps: list[str] = []
node = goal
while node != start:
prev, letter = came_from[node]
steps.append(letter)
node = prev
steps.reverse()
return steps
def _named_waypoints(world: World) -> dict[str, tuple[int, int]]:
"""Map each location KEY (inn/shop/healer/dungeon) to its door cell."""
return {loc.key: (loc.x, loc.y) for loc in world.locations}
_STEP_DELTAS: dict[tuple[int, int], str] = {(0, -1): "N", (0, 1): "S", (1, 0): "E", (-1, 0): "W"}
def _adjacent_open(
world: World, walkable: set[tuple[int, int]], door: tuple[int, int]
) -> tuple[int, int] | None:
"""Return a walkable, non-location cell orthogonally adjacent to *door*.
The cell the bot stands on to then step INTO the door (opening its menu). It
must itself not be another location door, or stepping would enter the wrong
building. ``None`` only for a door walled in on all four sides, which a
bundled world never has.
"""
dx, dy = door
for ox, oy in ((0, -1), (0, 1), (1, 0), (-1, 0)):
cell = (dx + ox, dy + oy)
if cell in walkable and world.location_at(*cell) is None:
return cell
return None
def _step_between(start: tuple[int, int], goal: tuple[int, int]) -> str | None:
"""Return the single cardinal step from *start* to an adjacent *goal*."""
return _STEP_DELTAS.get((goal[0] - start[0], goal[1] - start[1]))
def _zone_hunt_spots(
world: World, walkable: set[tuple[int, int]]
) -> list[tuple[int, tuple[int, int], Monster | None]]:
"""Return one huntable spot per zone: ``(tier_hi, cell, toughest_foe)``.
For each zone, the nearest-to-spawn reachable cell inside it (so the bot can
actually stand there and fight) plus the toughest COMMON (non-rare) foe in
the zone's band — the survivability yardstick. Rares are excluded from that
yardstick: they carry a low weight (they surface seldom) and the death-save
covers an unlucky tough draw, so gating the whole zone on a rare it almost
never meets would freeze the bot in the starter wood. Zones with no reachable
cell or no fightable foe are dropped, so every spot is a real hunting ground.
"""
out: list[tuple[int, tuple[int, int], Monster | None]] = []
for zone in world.zones:
cell = _nearest_in_zone(world, walkable, zone)
if cell is None:
continue
band = world.monsters_for_tier_band(zone.tier_lo, zone.tier_hi)
common = [m for m in band if not m.rare] or band
toughest = max(common, key=lambda m: m.hp + m.atk) if common else None
# A zone whose tier band holds no fightable foe is no hunting ground: a
# fight there only ever yields "nothing stirs". Drop it so the fallback
# in _best_hunt_spot (ranked[-1]) can never land the bot on a dead zone.
if toughest is None:
continue
out.append((zone.tier_hi, cell, toughest))
return out
def _nearest_in_zone(
world: World, walkable: set[tuple[int, int]], zone: object
) -> tuple[int, int] | None:
"""Return the reachable, non-door cell inside *zone* closest to the spawn.
Location-door cells are walkable (``is_walkable`` returns True for a door),
but standing on one flips the bot into that location's MENU — useless ground
for a forest fight. So door cells are skipped here, mirroring the filter in
:func:`_adjacent_open`, leaving only true open ground the bot can fight on.
"""
sx, sy = world.spawn
best: tuple[int, int] | None = None
best_d = None
for x, y in walkable:
if not zone.contains(x, y): # type: ignore[attr-defined]
continue
if world.location_at(x, y) is not None:
continue
d = abs(x - sx) + abs(y - sy)
if best_d is None or d < best_d:
best_d = d
best = (x, y)
return best
# ---------------------------------------------------------------------------
# CLI rendering
# ---------------------------------------------------------------------------
def cli_simulate(
pack_dir: Path,
days: int,
seed: int,
out: TextIO | None = None,
seeds: int | None = None,
) -> int:
"""Run the bot over *pack_dir* and print a readable balance report; return 0.
With *seeds* unset (or 1) this runs a single seed and prints its full
report. With ``seeds=K`` it runs seeds ``seed .. seed+K-1`` and prints an
AGGREGATE: per-seed one-liners plus the mean and spread of the headline
measures across the sweep — the form an author reads to judge whether a
world is reliably winnable and sanely paced, not just lucky on one seed.
"""
import sys
out = out if out is not None else sys.stdout
world = load_world(pack_dir)
count = seeds if seeds and seeds > 1 else 1
reports = [simulate(pack_dir, days, seed + i) for i in range(count)]
if count == 1:
print(_render_report(world.name, reports[0]), file=out)
else:
print(_render_sweep(world.name, days, reports), file=out)
return 0
def _render_report(world_name: str, r: BalanceReport) -> str:
"""Render a single-seed report as an aligned, human-readable block."""
wyrm = f"yes (first on day {r.day_of_first_wyrm_kill})" if r.wyrm_killed else "no"
satchel = ", ".join(r.ending_satchel) if r.ending_satchel else "(empty)"
lines = [
f"{world_name} — greedy bot, {r.days} days, seed {r.seed}",
f" final level : {r.final_level} "
"(level at the final bell; a Wyrm kill resets to 1 — read with Wyrm slain)",
f" gold earned : {r.total_gold_earned}",
f" fights fought : {r.fights_fought}",
f" fight share : {r.realized_fight_share:.0%} of turn-actions",
f" rungs cleared : {r.rungs_cleared}",
f" death-saves : {r.deaths_survived} survived, {r.deaths_taken} taken",
f" Wyrm slain : {wyrm}",
f" ending satchel : {satchel}",
]
return "\n".join(lines)
def _render_sweep(world_name: str, days: int, reports: list[BalanceReport]) -> str:
"""Render a multi-seed sweep: per-seed lines, then means and spreads."""
lines = [f"{world_name} — greedy bot sweep, {days} days, {len(reports)} seeds", ""]
for r in reports:
kill = f"day {r.day_of_first_wyrm_kill}" if r.wyrm_killed else "—"
lines.append(
f" seed {r.seed:>3}: Lv{r.final_level:<3} "
f"gold {r.total_gold_earned:>6} fights {r.fights_fought:>3} "
f"rungs {r.rungs_cleared} Wyrm {kill}"
)
lines.append("")
kills = sum(1 for r in reports if r.wyrm_killed)
kill_days = [r.day_of_first_wyrm_kill for r in reports if r.day_of_first_wyrm_kill is not None]
lines.append(" aggregate (mean [min..max]):")
lines.append(
f" final level : {_stat_line(r.final_level for r in reports)} "
"(level at the final bell; a Wyrm kill resets to 1 — read with Wyrm slain)"
)
lines.append(f" gold earned : {_stat_line(r.total_gold_earned for r in reports)}")
lines.append(f" fights fought : {_stat_line(r.fights_fought for r in reports)}")
lines.append(f" fight share : {_mean(r.realized_fight_share for r in reports):.0%}")
lines.append(f" rungs cleared : {_stat_line(r.rungs_cleared for r in reports)}")
lines.append(f" Wyrm slain : {kills}/{len(reports)} seeds")
if kill_days:
lines.append(f" first kill day: {_stat_line(iter(kill_days))}")
return "\n".join(lines)
def _stat_line(values: Iterable[int]) -> str:
"""Render ``mean [min..max]`` for an integer measure across the sweep."""
data = list(values)
if not data:
return "—"
return f"{sum(data) / len(data):.1f} [{min(data)}..{max(data)}]"
def _mean(values: Iterable[float]) -> float:
"""Return the arithmetic mean of *values* (0.0 when empty)."""
data = list(values)
return sum(data) / len(data) if data else 0.0