Files
turnstone/examples/door-game/tests/conftest.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

257 lines
8.3 KiB
Python

"""Shared test fixtures and builders.
These builders construct engine objects directly (no JSON loader) so the
engine tests stay independent of the content pack. Later chunks add
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
import pytest
from understone.engine.models import (
Item,
LocationDef,
Mode,
Monster,
Player,
Settings,
Slot,
TerrainDef,
WorldEvent,
Zone,
)
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
# ---------------------------------------------------------------------------
GRASS = TerrainDef(key="grass", glyph=".", walkable=True, encounter_rate=0.0, color="floor")
WALL = TerrainDef(key="wall", glyph="█", walkable=False, encounter_rate=0.0, color="wall")
WATER = TerrainDef(key="water", glyph="~", walkable=False, encounter_rate=0.0, color="water")
FOREST = TerrainDef(key="forest", glyph="↑", walkable=True, encounter_rate=1.0, color="tree")
SAFE_FOREST = TerrainDef(key="forest", glyph="↑", walkable=True, encounter_rate=0.0, color="tree")
DEFAULT_SETTINGS = Settings(
daily_turns=10,
rest_cost=15,
heal_cost_per_hp=2,
starting_gold=20,
starting_weapon="rusty_dagger",
starting_armor="cloth_tunic",
start_hp=20,
start_atk=3,
start_def=0,
xp_base=100,
growth_max_hp=6,
growth_atk=2,
growth_def=1,
bestow_daily_budget=25,
dungeon_tiers=(4, 5),
boss_monster="wyrm_below",
wyrm_min_level=6,
ambush_min_level=3,
ambush_level_band=2,
ambush_gold_pct=25,
post_daily_cap=5,
gamble_max_bet=50,
gamble_daily_cap=5,
satchel_max=3,
forge_base_cost=60,
forge_max_plus=3,
rare_drop_item="minor_potion",
forge_ore_item="iron_ore",
forge_ore_per_plus=1,
ore_dungeon_drop=2,
ore_forest_chance=0.2,
watch_theme="phosphor",
)
def make_settings(**overrides: object) -> Settings:
"""Return DEFAULT_SETTINGS with field overrides for band testing."""
base = {
"daily_turns": DEFAULT_SETTINGS.daily_turns,
"rest_cost": DEFAULT_SETTINGS.rest_cost,
"heal_cost_per_hp": DEFAULT_SETTINGS.heal_cost_per_hp,
"starting_gold": DEFAULT_SETTINGS.starting_gold,
"starting_weapon": DEFAULT_SETTINGS.starting_weapon,
"starting_armor": DEFAULT_SETTINGS.starting_armor,
"start_hp": DEFAULT_SETTINGS.start_hp,
"start_atk": DEFAULT_SETTINGS.start_atk,
"start_def": DEFAULT_SETTINGS.start_def,
"xp_base": DEFAULT_SETTINGS.xp_base,
"growth_max_hp": DEFAULT_SETTINGS.growth_max_hp,
"growth_atk": DEFAULT_SETTINGS.growth_atk,
"growth_def": DEFAULT_SETTINGS.growth_def,
"bestow_daily_budget": DEFAULT_SETTINGS.bestow_daily_budget,
"dungeon_tiers": DEFAULT_SETTINGS.dungeon_tiers,
"boss_monster": DEFAULT_SETTINGS.boss_monster,
"wyrm_min_level": DEFAULT_SETTINGS.wyrm_min_level,
"ambush_min_level": DEFAULT_SETTINGS.ambush_min_level,
"ambush_level_band": DEFAULT_SETTINGS.ambush_level_band,
"ambush_gold_pct": DEFAULT_SETTINGS.ambush_gold_pct,
"post_daily_cap": DEFAULT_SETTINGS.post_daily_cap,
"gamble_max_bet": DEFAULT_SETTINGS.gamble_max_bet,
"gamble_daily_cap": DEFAULT_SETTINGS.gamble_daily_cap,
"satchel_max": DEFAULT_SETTINGS.satchel_max,
"forge_base_cost": DEFAULT_SETTINGS.forge_base_cost,
"forge_max_plus": DEFAULT_SETTINGS.forge_max_plus,
"rare_drop_item": DEFAULT_SETTINGS.rare_drop_item,
"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)
return Settings(**base) # type: ignore[arg-type]
def make_player(**overrides: object) -> Player:
"""Build a Player at sane defaults; override any field by keyword."""
fields = {
"name": "Tester",
"x": 5,
"y": 5,
"hp": 20,
"max_hp": 20,
"level": 1,
"xp": 0,
"gold": 50,
"atk": 5,
"def_": 1,
"weapon_id": "rusty_dagger",
"armor_id": "cloth_tunic",
"turns_left": 10,
"turn_day": 0,
"mode": Mode.TILE,
"at_location": "",
"created_at": "2026-01-01T00:00:00+00:00",
"last_seen": "2026-01-01T00:00:00+00:00",
"log_cursor": 0,
"bestow_spent": 0,
"bestow_day": 0,
"wins": 0,
"posts_sent": 0,
"post_day": 0,
"gambles": 0,
"gamble_day": 0,
}
fields.update(overrides)
return Player(**fields) # type: ignore[arg-type]
def make_monster(**overrides: object) -> Monster:
"""Build a Monster at tier-1 defaults."""
fields = {
"tier": 1,
"name": "Field Rat",
"hp": 6,
"atk": 3,
"def_": 0,
"xp": 8,
"gold": 3,
"monster_id": "",
"boss": False,
}
fields.update(overrides)
return Monster(**fields) # type: ignore[arg-type]
def make_world(
*,
grid: list[list[TerrainDef]] | None = None,
width: int = 11,
height: int = 11,
spawn: tuple[int, int] = (5, 5),
locations: list[LocationDef] | None = None,
zones: list[Zone] | None = None,
monsters: list[Monster] | None = None,
items: list[Item] | None = None,
settings: Settings | None = None,
events: list[WorldEvent] | None = None,
) -> World:
"""Build a small synthetic World (all-grass by default)."""
if grid is None:
grid = [[GRASS for _ in range(width)] for _ in range(height)]
return World(
name="Test Vale",
width=width,
height=height,
spawn=spawn,
terrain=grid,
locations=locations or [],
zones=zones or [],
monsters=monsters or [make_monster()],
items=items or _default_items(),
settings=settings or DEFAULT_SETTINGS,
events=events,
)
def _default_items() -> list[Item]:
return [
Item("rusty_dagger", "Rusty Dagger", Slot.WEAPON, 2, 0, 0, 0),
Item("short_sword", "Short Sword", Slot.WEAPON, 5, 0, 0, 40),
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),
]
def fixed_clock(moment: datetime) -> Callable[[], datetime]:
"""Return a clock callable that always reports *moment*."""
def _clock() -> datetime:
return moment
return _clock
def utc(year: int, month: int, day: int, hour: int = 0, minute: int = 0) -> datetime:
"""Construct a tz-aware UTC datetime."""
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."""
return make_world()