diff --git a/CHANGELOG.md b/CHANGELOG.md index efad1c6..9a46a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 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/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 2614ee8..d1dabcc 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,108 @@ 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) @@ -339,11 +440,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 +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: diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 8bdfa47..b6df33d 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,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 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" },