Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- `LearnSpell`, a command that adds a spell to an arcane caster's spell book in-session — leveling's new capacity or a mentor's teaching made concrete, driving the core `add_spell_to_book` through the command loop so the addition is validated, logged, and replayed like any other command. Legal in town and while exploring, and no game time passes: the fiction around the learning (the mentor's week, a copied scroll's costs) belongs to the game. The per-spell-level capacity, the duplicate/wrong-list/non-arcane gates, and the book-never-shrinks rule are exactly the core function's, and a successful learning emits the existing `SpellBookUpdatedEvent`.
- `open_book_capacity` — the per-spell-level count of open spell-book slots: the progression row's capacity minus the spells held at that level, floored at zero (a drained caster's book may sit over capacity and simply reads as no openings), `()` for a class with no arcane book. `add_spell_to_book`'s capacity check now consults it, so the rule has one home a front end can share when rendering what a caster may still learn. Alongside it, `level_title` answers a class's level title at a given level, `None` beyond the printed list — the level event reads it.

### Fixed

- What the party has seen by its own light now persists as map memory. The 1.2.1 reveal was deliberately ephemeral: the player view computed the lit room and passages fresh on every build, so the moment the party walked on — or the torch guttered out — every cell it had glimpsed but not stepped on fell dark again, and a front end's automap could remember only the physically walked footprint. Seen cells now accumulate in `DungeonState.seen`, a second per-level cell list beside `explored`, written by the session after every accepted command from the same light flood the view draws; the player projection carries walked plus remembered-seen plus currently lit cells, so a mapped room stays on the map. Seen is memory of sight, never exploration: movement cost still reads the walked footprint (a lit-but-unwalked cell costs the full 30), drop-pile visibility stays on explored cells, an undiscovered secret door's sealed alcove is never recorded, and the referee view is unchanged. The field is additive with a default, so there is no schema bump and no migration — an older save loads with empty memory and simply starts remembering.

## [1.4.0] - 2026-07-28

### Added
Expand Down
8 changes: 6 additions & 2 deletions docs/guides/views-and-visibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ adventure's and town's public names and descriptions; each party member's own pu
sheet ([`MemberView`][osrlib.crawl.views.MemberView] — id, name, class, level, current
and max hit points, conditions, inventory, memorized spells — a player always sees
their own characters in full); the party's location and facing; the elapsed clock; the
session mode; the explored map, cell by cell, with its edges (an undiscovered secret
door renders as a plain wall — [`ExploredLevelView`][osrlib.crawl.views.ExploredLevelView]
session mode; the mapped cells with their edges — every cell the party has walked,
every cell its own light has shown it (persisted as map memory in
[`DungeonState.seen`][osrlib.crawl.dungeon.DungeonState.seen], so a front end's
automap remembers a torchlit room after the party walks on), and whatever its light
reveals from where it stands right now, while an undiscovered secret door renders as
a plain wall throughout ([`ExploredLevelView`][osrlib.crawl.views.ExploredLevelView]
and [`EdgeView`][osrlib.crawl.views.EdgeView]); known dropped piles and emptied
treasure caches in that explored space; active effects on party members with their
remaining duration (except a potion's — RAW has the referee track that secretly, so
Expand Down
40 changes: 33 additions & 7 deletions src/osrlib/crawl/dungeon.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
passages (`open`) and doors explicitly — and the level boundary is implicitly wall.
"""

from collections.abc import Iterable
from enum import StrEnum
from typing import Literal

Expand Down Expand Up @@ -659,19 +660,23 @@ class DropPile(BaseModel):
class DungeonState(BaseModel):
"""The mutable overlay play writes over the frozen adventure content.

References are strings so the overlay serializes flat: explored cells key by
`"{dungeon}:{level}"`, doors by [`edge_ref`][osrlib.crawl.dungeon.edge_ref],
traps and caches by `"{dungeon}:{level}:{area_or_feature_id}"`, piles by
[`cell_ref`][osrlib.crawl.dungeon.cell_ref]. Attempt memory (listen once per
character per door, search once per character per cell per kind, the pick-lock
lockout with the thief's level at failure) lives here too — it is game state,
not procedure-local bookkeeping.
References are strings so the overlay serializes flat: explored and seen
cells key by `"{dungeon}:{level}"`, doors by
[`edge_ref`][osrlib.crawl.dungeon.edge_ref], traps and caches by
`"{dungeon}:{level}:{area_or_feature_id}"`, piles by
[`cell_ref`][osrlib.crawl.dungeon.cell_ref]. `explored` is the party's
footprint — the cells it has physically entered — while `seen` is its map
memory: the cells its own light has shown it, read by the player projection
only. Attempt memory (listen once per character per door, search once per
character per cell per kind, the pick-lock lockout with the thief's level at
failure) lives here too — it is game state, not procedure-local bookkeeping.
"""

model_config = ConfigDict(validate_assignment=True)

location: PartyLocation = PartyLocation(kind="town")
explored: dict[str, list[Position]] = {}
seen: dict[str, list[Position]] = {}
doors: dict[str, DoorState] = {}
sprung_traps: list[str] = []
removed_traps: list[str] = []
Expand Down Expand Up @@ -717,6 +722,27 @@ def mark_explored(self, dungeon_id: str, level_number: int, position: Position)
elif position not in cells:
cells.append(position)

def mark_seen(self, dungeon_id: str, level_number: int, positions: Iterable[Position]) -> None:
"""Mark cells seen (idempotent): map memory, what the party has glimpsed by light.

Seen cells are read by the player projection only, so a front end's
automap remembers a room the party's light showed it after the party
walks on. They never affect movement cost — that reads the explored
footprint via [`is_explored`][osrlib.crawl.dungeon.DungeonState.is_explored]
— or drop-pile visibility, which stays on explored cells (piles only ever
form where the party has stood). New positions append in sorted `(x, y)`
order so serialization is deterministic across runs.

Args:
dungeon_id: The dungeon id.
level_number: The 1-based level number.
positions: The cells to remember; already-seen cells are skipped.
"""
key = f"{dungeon_id}:{level_number}"
fresh = sorted(set(positions) - set(self.seen.get(key, [])))
if fresh:
self.seen.setdefault(key, []).extend(fresh)

def door(self, ref: str) -> DoorState:
"""Return (creating on first touch) the mutable state for one door.

Expand Down
81 changes: 81 additions & 0 deletions src/osrlib/crawl/exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,87 @@ def _sight_line_feet(session) -> int | None:
return max(1, longest) * _CELL_FEET


_DEFAULT_LIGHT_FEET = 30


def _light_radius_feet(params) -> int:
"""The radius of one light-family effect, in feet.

Equipment and magic-item light sources store the radius under
`light_radius_feet`; the *light* spell family stores it under `radius_feet`.
Read whichever the source carries, falling back to the torch default.
"""
raw = params.get("light_radius_feet", params.get("radius_feet"))
return int(raw) if raw is not None else _DEFAULT_LIGHT_FEET


def _light_reveal(session) -> tuple[str | None, set[Position]]:
"""Cells the party sees *right now* by its own light, keyed to their level.

This is sight, not exploration: the party glimpses the lit room it stands in
and a few cells down open passages, but these cells never enter the persisted
explored set, so seeing a room never cheapens the movement of later walking
it. The session does persist the result as map memory — after every accepted
command it folds these cells into
[`DungeonState.seen`][osrlib.crawl.dungeon.DungeonState.seen], so a front
end's automap remembers the glimpsed room after the party walks on. Torchlight
fills the keyed room whole and spills through open doorways out to the light's
radius; walls, and shut or undiscovered doors, stop it. Empty unless the party
stands in a dungeon with a light burning.

Returns:
The `"{dungeon}:{level}"` explored-map key for the party's level and the
set of seen cells, or `(None, set())` when nothing is lit.
"""
location = session.dungeon_state.location
if location.kind != "dungeon":
return None, set()
lit, _ = session.party_light()
if not lit:
return None, set()
try:
level = session.adventure.dungeon(location.dungeon_id).level(location.level_number)
except ValueError:
return None, set()

from osrlib.crawl.session import LIGHT_EFFECT_KINDS

living_ids = {member.id for member in session.party.living_members()}
radius_cells = max(
(
_light_radius_feet(effect.definition.params) // _CELL_FEET
for effect in session.ledger.effects
if effect.target_ref in living_ids and effect.definition.kind in LIGHT_EFFECT_KINDS
),
default=_DEFAULT_LIGHT_FEET // _CELL_FEET,
)
origin = tuple(location.position)
# The keyed room the party stands in is lit to its far corners — you are
# standing inside it — so its open-connected cells reveal even past the
# torch's reach; elsewhere, light spills through open passages only within
# that straight-line (Chebyshev) reach. Both honour real passability: the
# flood only crosses an edge sight passes, so walls and shut or undiscovered
# doors stop it, and an alcove sealed off inside a keyed room stays dark.
area = level.area_at(origin)
in_room = {tuple(cell) for cell in area.cells} if area is not None else frozenset()
seen: set[Position] = {origin}
frontier = [origin]
while frontier:
cell = frontier.pop()
for direction in Direction:
neighbour = step(cell, direction)
if neighbour in seen or not level.in_bounds(neighbour):
continue
within_reach = max(abs(neighbour[0] - origin[0]), abs(neighbour[1] - origin[1])) <= radius_cells
if not (within_reach or neighbour in in_room):
continue
if not _sight_passes(session, level, location, cell, direction):
continue
seen.add(neighbour)
frontier.append(neighbour)
return f"{location.dungeon_id}:{location.level_number}", seen


# ---------------------------------------------------------------------- light gates


Expand Down
19 changes: 19 additions & 0 deletions src/osrlib/crawl/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,27 @@ def execute(self, command: Command) -> CommandResult:
self.listener_state[listener.key] = state
accumulated.extend(emitted)
self.event_log.extend(emitted)
self._persist_sight()
return CommandResult(accepted=True, events=tuple(accumulated))

def _persist_sight(self) -> None:
"""Fold the party's current light reveal into the seen map memory.

Runs after every accepted command — the one hook that covers every
reveal-changing action uniformly (entering, moving, stairs, doors,
lighting, placement, discovery, battle-flight relocation) — and calls
[`mark_seen`][osrlib.crawl.dungeon.DungeonState.mark_seen] with what
`_light_reveal` shows from the party's cell. Rejected commands change no
state, so they never reach it.
"""
from osrlib.crawl.exploration import _light_reveal

key, cells = _light_reveal(self)
if key is None or not cells:
return
dungeon_id, level_text = key.rsplit(":", 1)
self.dungeon_state.mark_seen(dungeon_id, int(level_text), cells)

def register_listener(self, listener: Listener) -> None:
"""Register a listener; it runs after each command in registration order.

Expand Down
113 changes: 23 additions & 90 deletions src/osrlib/crawl/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
projections from that state alone, never from the event log.

The player view is an enumerated whitelist: party public sheets, location and
facing, explored cells with their edges (secret doors only if discovered — an
undiscovered secret door renders as wall), known piles and emptied caches in
explored space, active effects on party members with remaining durations, the
elapsed clock, the mode, the current encounter/battle public state (names,
facing, the mapped cells with their edges — walked cells, remembered seen cells
the party's light has shown it, and what its light reveals right now (secret
doors only if discovered — an undiscovered secret door renders as wall), known
piles and emptied caches in explored space, active effects on party members
with remaining durations, the elapsed clock, the mode, the current
encounter/battle public state (names,
counts, distances, visible conditions — never HP), fatigue/exhaustion/deprivation
status, and the adventure's public prose. It never carries unexplored geometry,
undiscovered traps or secret doors, monster HP or stat internals,
Expand All @@ -25,8 +27,8 @@

from osrlib.core.effects import Condition, has_condition
from osrlib.core.items import MagicItemCategory, MagicItemInstance, magic_item_template
from osrlib.crawl.dungeon import Direction, EdgeKind, PartyLocation, Position, cell_ref, edge_ref, step
from osrlib.crawl.exploration import _CELL_FEET, EXHAUSTED_KIND, FATIGUE_KIND, _sight_passes
from osrlib.crawl.dungeon import Direction, EdgeKind, PartyLocation, Position, cell_ref, edge_ref
from osrlib.crawl.exploration import EXHAUSTED_KIND, FATIGUE_KIND, _light_reveal

__all__ = [
"EdgeView",
Expand Down Expand Up @@ -331,99 +333,30 @@ def _visible_cell_refs(session) -> set[str]:
return refs


_DEFAULT_LIGHT_FEET = 30


def _light_radius_feet(params) -> int:
"""The radius of one light-family effect, in feet.

Equipment and magic-item light sources store the radius under
`light_radius_feet`; the *light* spell family stores it under `radius_feet`.
Read whichever the source carries, falling back to the torch default.
"""
raw = params.get("light_radius_feet", params.get("radius_feet"))
return int(raw) if raw is not None else _DEFAULT_LIGHT_FEET


def _light_reveal(session) -> tuple[str | None, set[Position]]:
"""Cells the party sees *right now* by its own light, keyed to their level.

This is sight, not exploration: the party glimpses the lit room it stands in
and a few cells down open passages, but these cells never enter the persisted
explored set. So seeing a room never cheapens the movement of later walking
it, and stepping away lets the unwalked cells fall dark again. Torchlight
fills the keyed room whole and spills through open doorways out to the light's
radius; walls, and shut or undiscovered doors, stop it. Empty unless the party
stands in a dungeon with a light burning.

Returns:
The `"{dungeon}:{level}"` explored-map key for the party's level and the
set of seen cells, or `(None, set())` when nothing is lit.
"""
location = session.dungeon_state.location
if location.kind != "dungeon":
return None, set()
lit, _ = session.party_light()
if not lit:
return None, set()
try:
level = session.adventure.dungeon(location.dungeon_id).level(location.level_number)
except ValueError:
return None, set()

from osrlib.crawl.session import LIGHT_EFFECT_KINDS

living_ids = {member.id for member in session.party.living_members()}
radius_cells = max(
(
_light_radius_feet(effect.definition.params) // _CELL_FEET
for effect in session.ledger.effects
if effect.target_ref in living_ids and effect.definition.kind in LIGHT_EFFECT_KINDS
),
default=_DEFAULT_LIGHT_FEET // _CELL_FEET,
)
origin = tuple(location.position)
# The keyed room the party stands in is lit to its far corners — you are
# standing inside it — so its open-connected cells reveal even past the
# torch's reach; elsewhere, light spills through open passages only within
# that straight-line (Chebyshev) reach. Both honour real passability: the
# flood only crosses an edge sight passes, so walls and shut or undiscovered
# doors stop it, and an alcove sealed off inside a keyed room stays dark.
area = level.area_at(origin)
in_room = {tuple(cell) for cell in area.cells} if area is not None else frozenset()
seen: set[Position] = {origin}
frontier = [origin]
while frontier:
cell = frontier.pop()
for direction in Direction:
neighbour = step(cell, direction)
if neighbour in seen or not level.in_bounds(neighbour):
continue
within_reach = max(abs(neighbour[0] - origin[0]), abs(neighbour[1] - origin[1])) <= radius_cells
if not (within_reach or neighbour in in_room):
continue
if not _sight_passes(session, level, location, cell, direction):
continue
seen.add(neighbour)
frontier.append(neighbour)
return f"{location.dungeon_id}:{location.level_number}", seen


def _explored_levels(session):
reveal_key, reveal_cells = _light_reveal(session)
for key, cells in session.dungeon_state.explored.items():
dungeon_state = session.dungeon_state
keys = list(dungeon_state.explored)
keys.extend(key for key in dungeon_state.seen if key not in dungeon_state.explored)
for key in keys:
dungeon_id, level_text = key.rsplit(":", 1)
level_number = int(level_text)
try:
level = session.adventure.dungeon(dungeon_id).level(level_number)
except ValueError:
continue
# Visible equals explored plus what the party's own light reveals from the
# current cell (the spec's visible flag): the lit room and a few cells of
# open passage, drawn now without waiting on a footstep into each square.
visible = list(cells)
# Visible equals walked cells, plus the persisted seen cells the party's
# light has shown it (map memory — see `DungeonState.seen`), plus what its
# light reveals from the current cell right now (the spec's visible flag),
# so lighting a torch draws the room immediately, without a footstep or
# even a command between the ledger and the view.
visible = list(dungeon_state.explored.get(key, []))
known = set(visible)
for cell in dungeon_state.seen.get(key, []):
if cell not in known:
visible.append(cell)
known.add(cell)
if key == reveal_key:
known = set(cells)
visible.extend(cell for cell in reveal_cells if cell not in known)
edges: dict[str, EdgeView] = {}
for cell in visible:
Expand Down
Loading
Loading