From 703667bd7995fdd77d527df1821704886b3ad771 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 30 Jul 2026 10:09:57 -0700 Subject: [PATCH] Remember what torchlight showed: persist seen cells as map memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The light reveal (1.2.1) was deliberately ephemeral: the player view recomputed what the party sees by its own light on every build, so the moment the party walked on — or the torch guttered out — every cell it had glimpsed but never stepped on fell dark again. A front end's automap could therefore only remember the physically walked footprint, and a mapped room vanished behind the party (osr-web issue #23). The client cannot fix this itself; the projection is its only source of geometry. Persist sight as its own state, distinct from exploration: - `DungeonState.seen` holds the remembered cells beside `explored`, same `"{dungeon}:{level}"` key shape. `mark_seen` is an idempotent bulk append, new cells in sorted (x, y) order so serialization is deterministic across runs. - `GameSession.execute` folds the current light reveal into `seen` after every accepted command (`_persist_sight`) — the one hook that covers every reveal-changing action uniformly: entering, moving, stairs, doors, lighting, placement, discovery, battle-flight relocation. Rejected commands change no state, so they never reach it. - The projection unions walked, remembered-seen, and currently lit cells; the live reveal stays so lighting a torch reveals immediately, even when a light effect lands on the ledger without a command. - `_light_reveal` and its radius helpers move from views.py to exploration.py, beside `_sight_passes` — they are game logic now, not projection. 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 — no schema bump, no migration; an older save loads with empty memory and starts remembering. Adds TestSeenPersistence (walk-away persistence, save/load round-trip, pre-`seen` save loads clean, sealed-alcove no-leak) and mark_seen idempotence/ordering coverage; the leak property test's invariant becomes explored-or-seen, and the cross-level test now expects level 1's memory to survive the descent while the live reveal still never bleeds across levels. Claude-Session: https://claude.ai/code/session_01Gu9KVvucLb7NoquyNjmxxv --- CHANGELOG.md | 4 + docs/guides/views-and-visibility.md | 8 +- src/osrlib/crawl/dungeon.py | 40 ++++++++-- src/osrlib/crawl/exploration.py | 81 ++++++++++++++++++++ src/osrlib/crawl/session.py | 19 +++++ src/osrlib/crawl/views.py | 113 ++++++---------------------- tests/test_crawl_properties.py | 12 ++- tests/test_dungeon.py | 13 ++++ tests/test_exploration.py | 74 +++++++++++++++++- 9 files changed, 257 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab1a6e..22dee8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guides/views-and-visibility.md b/docs/guides/views-and-visibility.md index 2f334df..415bb88 100644 --- a/docs/guides/views-and-visibility.md +++ b/docs/guides/views-and-visibility.md @@ -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 diff --git a/src/osrlib/crawl/dungeon.py b/src/osrlib/crawl/dungeon.py index dc384be..f624c0f 100644 --- a/src/osrlib/crawl/dungeon.py +++ b/src/osrlib/crawl/dungeon.py @@ -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 @@ -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] = [] @@ -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. diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 7347ee6..f0a6298 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -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 diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index dc12e56..cc12696 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -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. diff --git a/src/osrlib/crawl/views.py b/src/osrlib/crawl/views.py index 423f991..dc46c29 100644 --- a/src/osrlib/crawl/views.py +++ b/src/osrlib/crawl/views.py @@ -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, @@ -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", @@ -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: diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index 7498366..5cd621d 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -261,17 +261,21 @@ def test_the_player_view_never_leaks(seed, commands): # Session flags are referee-only: the view carries no flag store at all. parsed_view = json.loads(blob) assert "flags" not in parsed_view - # Unexplored geometry, trap specs, and secret doors stay hidden. - explored = { + # Unexplored geometry, trap specs, and secret doors stay hidden: every + # projected cell is either walked (`explored`) or persisted map memory + # (`seen` — the live light reveal is folded into it after every accepted + # command, so nothing the view shows escapes the two sets). + mapped = { (dungeon_level, tuple(cell)) - for dungeon_level, cells in session.dungeon_state.explored.items() + for source in (session.dungeon_state.explored, session.dungeon_state.seen) + for dungeon_level, cells in source.items() for cell in cells } parsed = json.loads(blob) for level_view in parsed["explored"]: key = f"{level_view['dungeon_id']}:{level_view['level_number']}" for cell in level_view["cells"]: - assert (key, tuple(cell)) in explored + assert (key, tuple(cell)) in mapped assert "TrapSpec" not in blob and "trap_ref" not in blob # Monster internals: no HP fields beyond the party's own. if parsed.get("encounter"): diff --git a/tests/test_dungeon.py b/tests/test_dungeon.py index cf5fb62..eba4a21 100644 --- a/tests/test_dungeon.py +++ b/tests/test_dungeon.py @@ -137,6 +137,18 @@ def test_explored_marking_is_idempotent(self): assert state.is_explored("delve", 1, (1, 0)) assert not state.is_explored("delve", 2, (0, 0)) + def test_seen_marking_is_idempotent_and_deterministically_ordered(self): + state = DungeonState() + state.mark_seen("delve", 1, {(2, 0), (0, 0), (1, 0)}) # a set: input order must not matter + assert state.seen["delve:1"] == [(0, 0), (1, 0), (2, 0)] + state.mark_seen("delve", 1, [(1, 0), (3, 0), (3, 0)]) + assert state.seen["delve:1"] == [(0, 0), (1, 0), (2, 0), (3, 0)] + state.mark_seen("delve", 1, [(0, 0)]) + assert state.seen["delve:1"] == [(0, 0), (1, 0), (2, 0), (3, 0)] + assert "delve:2" not in state.seen + # Seeing is not exploring: the footprint stays empty. + assert not state.is_explored("delve", 1, (0, 0)) + def test_door_state_created_on_first_touch(self): state = DungeonState() ref = edge_ref("delve", 1, (2, 0), Direction.SOUTH) @@ -150,6 +162,7 @@ def test_state_overlay_round_trips(self): location=PartyLocation(kind="dungeon", dungeon_id="delve", level_number=1, position=(2, 1), facing="south") ) state.mark_explored("delve", 1, (0, 0)) + state.mark_seen("delve", 1, [(0, 0), (1, 0), (0, 1)]) state.door("delve:1:2,1:north").open = True state.sprung_traps.append("delve:1:pit_room") state.lock_failures["delve:1:2,1:north"] = {"pc-2": 1} diff --git a/tests/test_exploration.py b/tests/test_exploration.py index d80351e..37b1c9b 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -5,6 +5,8 @@ matters. """ +import json + import pytest from crawl_fixtures import STOCK_ROSTER, build_adventure, build_party @@ -1196,8 +1198,9 @@ def test_passed_save_anchors_the_area_to_the_cell_and_mutes_casts_there(self): 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.""" + onto those cells; the reveal is sight, never persisted as exploration (it + persists as `seen` map memory instead), so it can never cheapen the movement + of later walking that ground.""" @staticmethod def _cells(session) -> set: @@ -1298,9 +1301,11 @@ def test_reveal_does_not_bleed_into_another_level(self): 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"]} + remembered = {tuple(cell) for cell in session.dungeon_state.seen["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 + # plus the persisted map memory — the live light reveal, now shining on + # level 2, never augments level 1. + assert set(level1.cells) == walked | remembered def test_light_reveal_stays_out_of_the_referee_view(self): session = quiet_session() @@ -1310,3 +1315,64 @@ def test_light_reveal_stays_out_of_the_referee_view(self): 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 + + +class TestSeenPersistence: + """What the party has seen by light persists as map memory (`DungeonState.seen`), + distinct from the walked `explored` footprint: the projection remembers the + glimpsed cells after the light is gone, while movement cost, pile gating, and + the hidden geometry rules stay exactly as they were.""" + + _cells = staticmethod(TestLightReveal._cells) + _ignite = staticmethod(TestLightReveal._ignite) + + def test_seen_cells_stay_projected_after_the_party_walks_away(self): + # The issue scenario: the torch shows the corridor ahead, the party walks + # on, the light gutters out — and the automap still remembers the room. + session = quiet_session() + entered(session) # at (0, 0), torch burning + assert {(0, 0), (1, 0), (2, 0), (1, 1)} <= self._cells(session) + session.execute(MoveParty(direction=Direction.EAST)) # to (1, 0) + session.execute(MoveParty(direction=Direction.WEST)) # back to (0, 0) + session.execute(ExtinguishSource(character_id="character-0001")) + # No light burns, so nothing is *seen right now* — yet the glimpsed + # cells stay on the map as memory, still unwalked. + assert session.party_light()[0] is False + assert {(2, 0), (1, 1)} <= self._cells(session) + assert not session.dungeon_state.is_explored("delve", 1, (2, 0)) + assert not session.dungeon_state.is_explored("delve", 1, (1, 1)) + + def test_seen_survives_save_and_load(self): + from osrlib.persistence import load_game, save_game + + session = quiet_session() + entered(session) + session.execute(MoveParty(direction=Direction.EAST)) + assert session.dungeon_state.seen["delve:1"] + restored = load_game(json.loads(json.dumps(save_game(session)))) + assert restored.dungeon_state.seen == session.dungeon_state.seen + # The restored view still carries the remembered, unwalked cells. + assert (2, 0) in self._cells(restored) + assert not restored.dungeon_state.is_explored("delve", 1, (2, 0)) + + def test_an_old_save_without_the_seen_field_loads_clean(self): + from osrlib.persistence import load_game, save_game + + session = quiet_session() + entered(session) + document = json.loads(json.dumps(save_game(session))) + del document["payload"]["dungeon_state"]["seen"] # a pre-`seen` engine's save + restored = load_game(document) + assert restored.dungeon_state.seen == {} + assert restored.dungeon_state.explored == session.dungeon_state.explored + + def test_seen_never_records_a_sealed_alcove(self): + # The cell behind an undiscovered secret door is never seen, so walking + # away cannot leave it on the map either. + 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 (4, 1) not in {tuple(cell) for cell in session.dungeon_state.seen["delve:1"]} + place(session, (0, 0)) # walk away + assert (4, 1) not in self._cells(session)