From 8a7e1f9f159822a9de370bb28f52be46905963aa Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 20 Jul 2026 17:14:53 -0700 Subject: [PATCH 1/4] Reveal cells the party sees by torchlight in the player view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dungeon viewport and automap render only the cells carried by the player-view projection, which equated "visible" with "explored" — the cells the party had physically stepped on. A lit torch therefore changed nothing: every open square around the party stayed a black void until a footstep marked it explored. The client cannot fix this itself; the projection deliberately withholds unexplored geometry, so it never has the walls or doors of the surrounding room to draw. Compute what the party actually sees by its own light at view-build time and fold those cells into the current level's projection: - `_light_reveal` floods outward from the party's cell through open passages (and open, non-secret doors), bounded by the light's radius in cells, and includes the whole keyed room the party stands in. Walls and shut or undiscovered doors stop it. - `_explored_levels` unions the seen cells into the current level so the client draws the room and lit corridors with no client change. This is sight, not exploration: seen cells never enter the persisted explored set, so movement cost (30 for a new cell, 10 for a walked one) and map persistence are untouched. Visibility recomputes every view, so lighting a torch while standing still reveals the room immediately. Adds TestLightReveal: reveal-ahead-without-moving, unlit-shows-only- footprints, light-stops-at-a-closed-door, and whole-room reveal. Claude-Session: https://claude.ai/code/session_016qNYjfiRqHJFxHYbwFEEX9 --- src/osrlib/crawl/views.py | 105 +++++++++++++++++++++++++++++++++++--- tests/test_exploration.py | 47 +++++++++++++++++ 2 files changed, 146 insertions(+), 6 deletions(-) diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 2614ee8..04786c9 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -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__ = [ @@ -331,7 +331,96 @@ 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 _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( + ( + int(effect.definition.params.get("light_radius_feet", _DEFAULT_LIGHT_FEET)) // _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) + # Flood outward through open passages, bounded by straight-line (Chebyshev) + # reach so the torch lights the whole small room but only a few cells of + # corridor. Every reached cell stays within the light's radius of the party. + 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 + if max(abs(neighbour[0] - origin[0]), abs(neighbour[1] - origin[1])) > radius_cells: + continue + if not _sight_passes(session, level, location, cell, direction): + continue + seen.add(neighbour) + frontier.append(neighbour) + # The keyed room the party stands in reads as lit to its far corners — you + # are standing inside it — even where those corners outrun the torch's radius. + area = level.area_at(origin) + if area is not None: + seen.update(tuple(cell) for cell in area.cells) + 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) @@ -339,11 +428,15 @@ def _explored_levels(session): 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: @@ -362,7 +455,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: diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 8bdfa47..d800c99 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -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 @@ -835,3 +836,49 @@ 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) + + 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 + 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): + break + assert session.party_light()[0] is True + assert {(2, 1), (3, 1), (2, 2), (3, 2)} <= self._cells(session) From c8433b3ebdae954147c2fbe8c302ac1eda5d7cd2 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 20 Jul 2026 17:36:25 -0700 Subject: [PATCH 2/4] Address rubber-duck review: light radius param + area sight-gate Two findings from an adversarial review of the torchlight reveal: - The *light* spell family stores its radius under `radius_feet`, while equipment and magic items use `light_radius_feet`. `_light_reveal` read only the latter, so a party lit by a 15-foot *light* spell fell back to the 30-foot default and over-revealed corridor geometry. Add `_light_radius_feet`, which reads whichever key the source carries. - The whole-room reveal unioned every cell of the party's keyed area unconditionally, bypassing the `_sight_passes` gate. No shipped content authors a room split by an internal door, but an alcove behind a closed or secret door would have leaked. Fold the room reveal into the sight-gated flood (admit a neighbour within the light's reach *or* in the party's own room, but only across an edge sight actually crosses), so a sealed-off alcove stays dark while the open room still lights whole. Adds regression tests: undiscovered secret door stays a wall under light, the *light*-spell radius reveals less than a torch, seeing a cell by light doesn't change its movement cost, the reveal doesn't bleed into another level, and it never enters the referee view. Claude-Session: https://claude.ai/code/session_016qNYjfiRqHJFxHYbwFEEX9 --- src/osrlib/crawl/views.py | 32 +++++++++++----- tests/test_exploration.py | 80 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 04786c9..d1dabcc 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -337,6 +337,17 @@ def _visible_cell_refs(session) -> set[str]: _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. @@ -387,16 +398,21 @@ def _light_reveal(session) -> tuple[str | None, set[Position]]: living_ids = {member.id for member in session.party.living_members()} radius_cells = max( ( - int(effect.definition.params.get("light_radius_feet", _DEFAULT_LIGHT_FEET)) // _CELL_FEET + _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) - # Flood outward through open passages, bounded by straight-line (Chebyshev) - # reach so the torch lights the whole small room but only a few cells of - # corridor. Every reached cell stays within the light's radius of the party. + # 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: @@ -405,17 +421,13 @@ def _light_reveal(session) -> tuple[str | None, set[Position]]: neighbour = step(cell, direction) if neighbour in seen or not level.in_bounds(neighbour): continue - if max(abs(neighbour[0] - origin[0]), abs(neighbour[1] - origin[1])) > radius_cells: + 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) - # The keyed room the party stands in reads as lit to its far corners — you - # are standing inside it — even where those corners outrun the torch's radius. - area = level.area_at(origin) - if area is not None: - seen.update(tuple(cell) for cell in area.cells) return f"{location.dungeon_id}:{location.level_number}", seen diff --git a/tests/test_exploration.py b/tests/test_exploration.py index d800c99..b6df33d 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -849,6 +849,19 @@ def _cells(session) -> set: 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 @@ -876,9 +889,68 @@ 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 - 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): - break + 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 From 927b041042bfc42c1e1dd3129fa22503dd6f87b3 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 20 Jul 2026 17:45:26 -0700 Subject: [PATCH 3/4] Add CHANGELOG entry for the torchlight reveal Per the release process, a PR that changes user-visible behavior records its bullet under [Unreleased] in the same PR. Claude-Session: https://claude.ai/code/session_016qNYjfiRqHJFxHYbwFEEX9 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index efad1c6..23e5ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### 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 From a82d41c27e94602e4313f2d34e741b200ffdacfa Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 20 Jul 2026 17:48:44 -0700 Subject: [PATCH 4/4] Release 1.2.1 Bump the version and cut the [1.2.1] changelog section (the torchlight reveal fix). Patch release: backward-compatible bug fix, public API surface and schema_version unchanged. Local dry run green: full gate, uv build, check_dist, and a fresh-venv install_smoke all pass for 1.2.1; goldens keep their engine stamp (no regeneration). Claude-Session: https://claude.ai/code/session_016qNYjfiRqHJFxHYbwFEEX9 --- CHANGELOG.md | 5 ++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e5ddb..9a46a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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. @@ -42,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 diff --git a/pyproject.toml b/pyproject.toml index 4cf8dda..2dd39e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/uv.lock b/uv.lock index 7c87f1d..af6ee22 100644 --- a/uv.lock +++ b/uv.lock @@ -487,7 +487,7 @@ wheels = [ [[package]] name = "osrlib" -version = "1.2.0" +version = "1.2.1" source = { editable = "." } dependencies = [ { name = "pydantic" },