Files
turnstone/examples/door-game/understone/engine/models.py
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

233 lines
6.6 KiB
Python

"""Core data models for the game engine.
All models are plain dataclasses. ``Player`` is mutable (the engine applies
deltas in place); the static content models (``Monster``, ``Item``,
``TerrainDef``, ``LocationDef``, ``Zone``) are frozen.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
class Mode(StrEnum):
"""Which interaction surface the player is currently on."""
TILE = "tile"
MENU = "menu"
class Slot(StrEnum):
"""Equipment / item slot kinds."""
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)
class Player:
"""A single adventurer's durable state.
Coordinates are map cells; ``mode`` and ``at_location`` track whether
the player is on the overworld or inside a location menu. Turn fields
gate the daily action budget; bestow fields gate the daily fortune pool.
"""
name: str
x: int
y: int
hp: int
max_hp: int
level: int
xp: int
gold: int
atk: int
def_: int
weapon_id: str
armor_id: str
turns_left: int
turn_day: int
mode: Mode
at_location: str
created_at: str
last_seen: str
log_cursor: int
bestow_spent: int
bestow_day: int
wins: int = 0
posts_sent: int = 0
post_day: int = 0
gambles: int = 0
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
# 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)
class Monster:
"""A static monster definition from the content pack.
``boss`` monsters are the fixed endgame foe (the Wyrm Below): they are
excluded from random tier-band selection and only ever faced through the
deliberate ``challenge`` verb.
"""
tier: int
name: str
hp: int
atk: int
def_: int
xp: int
gold: int
monster_id: str = ""
boss: bool = False
# v0.7 weighted forest encounters: ``weight`` biases the random pick (a
# low weight surfaces seldom), ``rare`` marks a named beast that fires a
# public Herald flash and drops a guaranteed draught on the kill. Rung
# guardians ignore both (a rung is a fixed foe, never a weighted roll).
weight: int = 10
rare: bool = False
@dataclass(frozen=True, slots=True)
class Item:
"""A static item / equipment definition from the content pack."""
item_id: str
name: str
slot: Slot
atk: int
def_: int
heal: int
price: int
@dataclass(frozen=True, slots=True)
class TerrainDef:
"""A terrain kind: its glyph, walkability, encounter rate, colour role."""
key: str
glyph: str
walkable: bool
encounter_rate: float
color: str
@dataclass(frozen=True, slots=True)
class LocationDef:
"""A named location placed on the map (inn, shop, healer, dungeon)."""
key: str
kind: str
name: str
x: int
y: int
glyph: str
color: str
actions: tuple[str, ...]
flavor: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True, slots=True)
class Zone:
"""A rectangular region that biases which monster tiers spawn."""
key: str
x0: int
y0: int
x1: int
y1: int
tier_lo: int
tier_hi: int
def contains(self, x: int, y: int) -> bool:
"""Return whether ``(x, y)`` falls inside this zone's rectangle."""
return self.x0 <= x <= self.x1 and self.y0 <= y <= self.y1
@dataclass(frozen=True, slots=True)
class WorldEvent:
"""One row of the weighted overworld encounter table.
``kind`` is one of ``fight``/``gold``/``heal``/``trap``/``lore``.
``weight`` biases random selection. ``lo``/``hi`` bound the rolled amount
for the value-bearing kinds (gold/heal/trap); they are unused for
``fight`` (the foe comes from the zone band) and ``lore`` (pure flavour).
"""
kind: str
weight: int
text: str
lo: int
hi: int
@dataclass(frozen=True, slots=True)
class Settings:
"""Economy and progression parameters sourced from the content pack."""
daily_turns: int
rest_cost: int
heal_cost_per_hp: int
starting_gold: int
starting_weapon: str
starting_armor: str
start_hp: int
start_atk: int
start_def: int
xp_base: int
growth_max_hp: int
growth_atk: int
growth_def: int
bestow_daily_budget: int
dungeon_tiers: tuple[int, ...]
boss_monster: str
wyrm_min_level: int
ambush_min_level: int
ambush_level_band: int
ambush_gold_pct: int
post_daily_cap: int
gamble_max_bet: int
gamble_daily_cap: int
# v0.7 "depth below": the carried-potion satchel size, the forge cost
# ladder (base * (current_plus + 1)) and its enhancement ceiling, and the
# consumable item a rare beast is guaranteed to drop on its kill.
satchel_max: int
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.
watch_theme: str = "phosphor"