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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

## [1.2.1] - 2026-07-20

### Fixed

- The player view's explored cells now include what the party sees by its own light from its current cell — the lit room it stands in and open passages out to the light source's radius (a torch's 30 feet, the *light* spell's 15) — rather than only the cells it has physically entered. A front end drawing `PlayerView.explored` renders the torchlit room at once instead of leaving it dark until the party steps onto each square. The reveal is sight, not exploration: it never enters the persisted explored set (so movement cost and map memory are unchanged), it stops at walls and shut or undiscovered doors, and it never reaches the referee view.

## [1.2.0] - 2026-07-17

### Added
Expand Down Expand Up @@ -38,7 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- The documentation site: quickstart, guides, front-end walk-throughs, and a full reference for every command, event, rejection code, message code, RNG stream, and content id.
- The typed surface: complete type hints under `py.typed`, checked in CI.

[Unreleased]: https://github.com/mmacy/osrlib-python/compare/v1.2.0...HEAD
[Unreleased]: https://github.com/mmacy/osrlib-python/compare/v1.2.1...HEAD
[1.2.1]: https://github.com/mmacy/osrlib-python/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/mmacy/osrlib-python/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/mmacy/osrlib-python/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/mmacy/osrlib-python/releases/tag/v1.0.0
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "osrlib"
version = "1.2.0"
version = "1.2.1"
description = "B/X (1981 Basic/Expert) tabletop RPG rules engine for turn-based dungeon crawlers"
readme = "README.md"
# The wheel ships Open Game Content (osrlib/data/*.json and its LICENSE-OGL.md
Expand Down
117 changes: 111 additions & 6 deletions src/osrlib/crawl/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

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
from osrlib.crawl.dungeon import Direction, EdgeKind, PartyLocation, Position, cell_ref, edge_ref, step
from osrlib.crawl.exploration import EXHAUSTED_KIND, FATIGUE_KIND

__all__ = [
Expand Down Expand Up @@ -331,19 +331,124 @@ def _visible_cell_refs(session) -> set[str]:
return refs


# The dungeon grid is authored at the classic ten-foot square, so a torch's
# thirty-foot radius reaches three cells of open floor.
_CELL_FEET = 10
_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 _sight_passes(session, level, location, cell: Position, direction: Direction) -> bool:
"""Whether torchlight (and sight) crosses one cell edge.

Open floor and an open, non-secret door let light through; walls, blocked
edges, and shut or undiscovered-secret doors stop it — the same passability
the mover and the edge projection already agree on.
"""
edge = level.edge(cell, direction)
if edge.kind is EdgeKind.OPEN:
return True
if edge.kind is EdgeKind.DOOR:
ref = edge_ref(location.dungeon_id, location.level_number, cell, direction)
state = session.dungeon_state.doors.get(ref)
if edge.door.kind == "secret" and (state is None or not state.discovered):
return False
return bool(state.open) if state is not None else edge.door.starts_open
return False


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_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 the current cell (the named simplification
# of the spec's visible flag, registered); the current cell is explored
# on arrival, so the explored set is the visible set.
# 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)
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 cells:
for cell in visible:
for direction in Direction:
key_text = _canonical_edge(cell, direction)
if key_text in edges:
Expand All @@ -362,7 +467,7 @@ def _explored_levels(session):
)
else:
edges[key_text] = EdgeView(kind=edge.kind.value)
yield ExploredLevelView(dungeon_id=dungeon_id, level_number=level_number, cells=tuple(cells), edges=edges)
yield ExploredLevelView(dungeon_id=dungeon_id, level_number=level_number, cells=tuple(visible), edges=edges)


def _canonical_edge(cell: Position, direction: Direction) -> str:
Expand Down
119 changes: 119 additions & 0 deletions tests/test_exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from crawl_fixtures import build_adventure, build_party
from osrlib.core.clock import ROUNDS_PER_TURN, TimeUnit
from osrlib.core.effects import Condition, has_condition
from osrlib.core.events import Visibility
from osrlib.core.rng import RngStream
from osrlib.core.ruleset import Ruleset
from osrlib.crawl import exploration
Expand Down Expand Up @@ -835,3 +836,121 @@ def test_passed_save_anchors_the_area_to_the_cell_and_mutes_casts_there(self):
assert muted.rejections[0].code == "magic.cast.silenced_area"
return
raise AssertionError("no seed passed the silence save in forty tries")


class TestLightReveal:
"""A lit party sees the room and passages its torch reaches before it steps
onto those cells; the reveal is sight, never persisted as exploration, so it
can never cheapen the movement of later walking that ground."""

@staticmethod
def _cells(session) -> set:
view = session.view(Visibility.PLAYER)
level = next(entry for entry in view.explored if entry.level_number == 1)
return set(level.cells)

@staticmethod
def _edges(session) -> dict:
view = session.view(Visibility.PLAYER)
return next(entry for entry in view.explored if entry.level_number == 1).edges

@staticmethod
def _ignite(session) -> None:
for _ in range(20): # tinder is 2-in-6 per round; retry until the torch takes
lit = session.execute(LightSource(character_id="character-0001", item_id="torch"))
if any(event.code == "exploration.light.lit" for event in lit.events):
return
raise AssertionError("torch never lit in twenty tinder attempts")

def test_torchlight_reveals_the_open_cells_ahead_without_moving(self):
session = quiet_session()
entered(session) # the party stands at the entrance (0, 0), torch burning
cells = self._cells(session)
# The corridor east and the pit-room opening south are seen from here.
assert {(0, 0), (1, 0), (2, 0), (1, 1)} <= cells
# Seeing a cell is not walking it: the explored set stays a footprint.
assert not session.dungeon_state.is_explored("delve", 1, (1, 0))
assert not session.dungeon_state.is_explored("delve", 1, (2, 0))

def test_unlit_party_sees_only_where_it_has_walked(self):
session = quiet_session()
session.execute(EnterDungeon(dungeon_id="delve")) # inside, but no torch lit
assert session.party_light()[0] is False
assert self._cells(session) == {(0, 0)}

def test_torchlight_stops_at_a_closed_door(self):
session = quiet_session()
entered(session)
# room_a lies behind the stuck (closed) door on (2, 0)'s south edge.
room_a = {(2, 1), (3, 1), (2, 2), (3, 2)}
assert room_a.isdisjoint(self._cells(session))

def test_torchlight_reveals_the_whole_room_it_stands_in(self):
session = quiet_session()
session.execute(EnterDungeon(dungeon_id="delve"))
place(session, (2, 1)) # stand inside room_a
self._ignite(session)
assert session.party_light()[0] is True
assert {(2, 1), (3, 1), (2, 2), (3, 2)} <= self._cells(session)

def test_undiscovered_secret_door_stays_a_wall_under_light(self):
# A lit party beside an undiscovered secret door must see neither the
# cell behind it nor the door itself — it renders as solid rock.
session = quiet_session()
session.execute(EnterDungeon(dungeon_id="delve"))
place(session, (3, 1)) # room_a, west of the secret door on (3, 1)'s east edge
self._ignite(session)
assert session.party_light()[0] is True
assert (4, 1) not in self._cells(session)
assert self._edges(session)["4,1:west"].kind == "wall"

def test_light_spell_radius_reveals_less_than_a_torch(self):
# The *light* spell keeps its radius under `radius_feet` (not the
# equipment key `light_radius_feet`); its 15 feet reach one cell, where a
# 30-foot torch would reach three. Regression for the param-name mismatch.
from osrlib.core.effects import EffectDefinition

session = quiet_session()
session.execute(EnterDungeon(dungeon_id="delve")) # entrance (0, 0), no torch lit
session.ledger.attach(
EffectDefinition(kind="light", params={"effect_kind": "light", "radius_feet": 15}),
"character-0001",
clock=session.clock,
allocator=session.allocator,
registry=session.registry(),
)
assert session.party_light()[0] is True
cells = self._cells(session)
assert (1, 0) in cells # one cell east — within a 15-foot reach
assert (2, 0) not in cells # two cells east — a torch would show it, this light must not

def test_seeing_a_cell_by_light_does_not_change_movement_cost(self):
# Sight is not exploration: a cell lit but never walked still costs the
# full unexplored-cell movement when the party finally steps onto it.
session = quiet_session()
entered(session) # at (0, 0); (1, 0) is revealed by torchlight, unwalked
assert (1, 0) in self._cells(session)
assert not session.dungeon_state.is_explored("delve", 1, (1, 0))
session.execute(MoveParty(direction=Direction.EAST)) # step onto (1, 0)
assert session.odometer_thirds == 30 # the new-cell cost, not the 10 of familiar ground

def test_reveal_does_not_bleed_into_another_level(self):
session = quiet_session()
entered(session)
place(session, (4, 1)) # the stairs cell
session.execute(UseStairs()) # descend to level 2; the torch still burns
view = session.view(Visibility.PLAYER)
level1 = next(entry for entry in view.explored if entry.level_number == 1)
walked = {tuple(cell) for cell in session.dungeon_state.explored["delve:1"]}
# Off the party's current level, the projection is the walked footprint
# only — the light reveal never augments another level.
assert set(level1.cells) == walked

def test_light_reveal_stays_out_of_the_referee_view(self):
session = quiet_session()
entered(session) # (1, 0) is revealed to the player, never walked
assert (1, 0) in self._cells(session)
referee = session.view(Visibility.REFEREE)
walked = {tuple(cell) for cell in referee.state["dungeon_state"]["explored"]["delve:1"]}
assert (1, 0) not in walked # sight is the player's alone; the referee sees only footprints
assert (0, 0) in walked
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading