From e3e6ab24df603fb31652a07e305f7324eeecb4a2 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 18:51:07 -0700 Subject: [PATCH 1/3] Contract: discovering a secret door refunds both of its cells (chunk refund-both-cells) Acceptance tests marked expected-to-fail: a discovery from the near side, by search or by referee command, refunds the far cell's room-trap attempts. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- tests/test_exploration.py | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 40fbd2b..29e50ed 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -1971,3 +1971,46 @@ def test_a_member_without_the_effect_gains_it_while_the_others_already_have_it(s assert gained and gained[-1].code == "exploration.fatigue.gained" assert all(len(session.ledger.active_on(member.id, exploration.FATIGUE_KIND)) == 1 for member in members) assert exploration.check_fatigue(session) == [] + + +class TestDiscoveringASecretDoorRefundsBothCells: + """A secret door is one edge shared by two cells, and a `room_traps` search from either cell covers + it, so discovering the door refunds the `room_traps` attempts of both cells, whichever side the + discovery came from and however it came (a search or `SetDoorState`).""" + + SEED = 3 # member 0002's secret-door search from (3,1) finds the east door + + @staticmethod + def searched_far_side_then_stand_near(seed: int) -> GameSession: + session = quiet_session(seed=seed) + entered(session) + place(session, (4, 1), facing=Direction.WEST) # the corridor beyond room_a's secret door + assert session.execute(Search(character_id="character-0001", kind="room_traps")).accepted + place(session, (3, 1), facing=Direction.EAST) # inside room_a, the door's other side + return session + + @pytest.mark.xfail(reason="chunk: refund-both-cells") + def test_a_search_from_the_near_side_refunds_the_far_side(self): + session = self.searched_far_side_then_stand_near(self.SEED) + found = session.execute(Search(character_id="character-0002", kind="secret_doors")) + assert "secret_door:east" in next(e for e in found.events if e.code == "exploration.search.found").found + place(session, (4, 1), facing=Direction.WEST) + again = session.execute(Search(character_id="character-0001", kind="room_traps")) + assert again.accepted, [rejection.code for rejection in again.rejections] + + @pytest.mark.xfail(reason="chunk: refund-both-cells") + def test_a_referee_discovery_refunds_the_far_side(self): + session = self.searched_far_side_then_stand_near(self.SEED) + assert session.execute( + SetDoorState(dungeon_id="delve", level_number=1, x=3, y=1, direction=Direction.EAST, discovered=True) + ).accepted + place(session, (4, 1), facing=Direction.WEST) + again = session.execute(Search(character_id="character-0001", kind="room_traps")) + assert again.accepted, [rejection.code for rejection in again.rejections] + + def test_a_cell_beyond_the_door_that_never_searched_is_unaffected(self): + session = self.searched_far_side_then_stand_near(self.SEED) + assert session.execute( + SetDoorState(dungeon_id="delve", level_number=1, x=3, y=1, direction=Direction.EAST, discovered=True) + ).accepted + assert session.dungeon_state.search_attempts.get("cell:delve:1:3,1:secret_doors", []) == [] From e9153b39360badb37ce87fee7ab869a4d0fe9468 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 18:56:34 -0700 Subject: [PATCH 2/3] Discovering a secret door refunds both of its cells (#129) A secret door is one edge shared by two cells, and a room_traps search from either cell covers that edge, so an undiscovered secret door hides the trap beyond it from both sides. _refund_trap_search now takes the discovered door's direction and clears the room_traps attempts of the cell across the edge as well as the cell the discovery was named from, on both discovery paths: a secret_doors search in _reveal and the referee's SetDoorState in _handle_set_door_state. Other kinds and other cells keep their attempts. Also appends a sentence to the _migrate_3_to_4 docstring in persistence.py noting that the message code the dropped surrender flag's event once carried is gone too, so an event still naming it would no longer parse, though no log holds one. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- docs/adaptations.md | 2 +- src/osrlib/crawl/commands.py | 14 ++++++++------ src/osrlib/crawl/exploration.py | 33 ++++++++++++++++++++------------- src/osrlib/crawl/session.py | 4 +++- src/osrlib/persistence.py | 5 ++++- 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/docs/adaptations.md b/docs/adaptations.md index bb128bf..fd2c3d5 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -308,7 +308,7 @@ Any door-forcing attempt sets the noise flag for the next wandering check (the b ### Searching and traps -Searching covers the party's current cell (the 10' × 10' area is the cell), costs a turn, is once per character per cell per kind, is rolled regardless of contents, and reveals every matching hidden feature on the cell. Treasure-trap find and removal are once per trap per character each, and a failed removal springs the trap (the classic reading; RAW says only "attempted once per trap"). Found traps stop springing on movement (the party walks around the known pit); the player-facing `exploration.trap.safe` emits only for a *known* trap whose trigger resolved without springing — an unknown trap's spring die stays referee-only. Finding is worth different things by kind, pinned: a found *room* trap never springs at all, at its area's edge or at one of its doors, while a found *treasure* trap still rolls its 2-in-6 on every `TakeTreasure` until a thief removes it, because in B/X anyone can find and avoid a room trap and only a thief can take a treasure trap out. `exploration.trap.safe` is therefore a cache outcome only: a door or an area edge past a found room trap rolls no die and so has none to report. A trap transition (slides) relocates the whole party — the party model has one location (pinned simplification). The darts volley resolves as one damage application whose rolls are every dart's die. `TakeTreasure`'s treasure trap springs on the character who reaches in: the command's `recipient_id` when one is named, otherwise the first living member in marching order (pinned). A room trap's `trigger` names its springing action: `enter` rolls on entering a cell of the area, `open` rolls on opening a door of the area — `OpenDoor` or a successful `ForceDoor` — with the same 2-in-6 on every opening until the one spring. A door belongs to the area from either adjoining cell (pinned: the blade over the lintel drops on the way out too), the far side rolling first should one door join two trapped areas, and each spring lands on the opener — the forcing character, else the first living member in marching order — passing to the next member standing if an earlier spring killed the opener. A trap needs a living victim: a party with no one left rolls no spring die, and a spring whose effect carries the party away from the door (a chute) cancels the springs behind it — the party is no longer performing the opening (all pinned). A `room_traps` search covers the searched cell's door edges, so the `open`-trigger trap beyond a known door is findable from the threatened side before the door is opened; an undiscovered secret door hides its trap along with itself (the no-leak doctrine), and a found door trap never springs, like the walked-around pit. Because those two rules compose into an unfindable trap, discovering a secret door on a cell clears that cell's `room_traps` attempts, whether the discovery came from a `secret_doors` search or from the referee's `SetDoorState` with `discovered=True` (pinned: the discovery is new information about the 10' square, so a re-search is earned). `secret_doors` and `construction` attempts on that cell, and every attempt on every other cell, are untouched. A treasure trap's only springing action is opening its cache, so the model rejects `trigger="enter"` on `kind="treasure"` — schema version 3, with a lossless migration rewriting the dead value on load. Locked by `test_exploration.py::TestSearching`, `TestTreasureTraps`, `TestDoorTraps`, `TestTwoTrappedAreas`, `TestTrapResolutionCensus`, and `TestDiscoveringASecretDoorRefundsTheCellsTrapSearch`. +Searching covers the party's current cell (the 10' × 10' area is the cell), costs a turn, is once per character per cell per kind, is rolled regardless of contents, and reveals every matching hidden feature on the cell. Treasure-trap find and removal are once per trap per character each, and a failed removal springs the trap (the classic reading; RAW says only "attempted once per trap"). Found traps stop springing on movement (the party walks around the known pit); the player-facing `exploration.trap.safe` emits only for a *known* trap whose trigger resolved without springing — an unknown trap's spring die stays referee-only. Finding is worth different things by kind, pinned: a found *room* trap never springs at all, at its area's edge or at one of its doors, while a found *treasure* trap still rolls its 2-in-6 on every `TakeTreasure` until a thief removes it, because in B/X anyone can find and avoid a room trap and only a thief can take a treasure trap out. `exploration.trap.safe` is therefore a cache outcome only: a door or an area edge past a found room trap rolls no die and so has none to report. A trap transition (slides) relocates the whole party — the party model has one location (pinned simplification). The darts volley resolves as one damage application whose rolls are every dart's die. `TakeTreasure`'s treasure trap springs on the character who reaches in: the command's `recipient_id` when one is named, otherwise the first living member in marching order (pinned). A room trap's `trigger` names its springing action: `enter` rolls on entering a cell of the area, `open` rolls on opening a door of the area — `OpenDoor` or a successful `ForceDoor` — with the same 2-in-6 on every opening until the one spring. A door belongs to the area from either adjoining cell (pinned: the blade over the lintel drops on the way out too), the far side rolling first should one door join two trapped areas, and each spring lands on the opener — the forcing character, else the first living member in marching order — passing to the next member standing if an earlier spring killed the opener. A trap needs a living victim: a party with no one left rolls no spring die, and a spring whose effect carries the party away from the door (a chute) cancels the springs behind it — the party is no longer performing the opening (all pinned). A `room_traps` search covers the searched cell's door edges, so the `open`-trigger trap beyond a known door is findable from the threatened side before the door is opened; an undiscovered secret door hides its trap along with itself (the no-leak doctrine), and a found door trap never springs, like the walked-around pit. Because those two rules compose into an unfindable trap, discovering a secret door clears the `room_traps` attempts of both cells the door's edge joins, whichever side the discovery was named from and whether it came from a `secret_doors` search or from the referee's `SetDoorState` with `discovered=True` (pinned: the discovery is new information about both cells' 10' squares, so a re-search is earned on either side). `secret_doors` and `construction` attempts on those two cells, and every attempt on every other cell, are untouched. A treasure trap's only springing action is opening its cache, so the model rejects `trigger="enter"` on `kind="treasure"` — schema version 3, with a lossless migration rewriting the dead value on load. Locked by `test_exploration.py::TestSearching`, `TestTreasureTraps`, `TestDoorTraps`, `TestTwoTrappedAreas`, `TestTrapResolutionCensus`, `TestDiscoveringASecretDoorRefundsTheCellsTrapSearch`, and `TestDiscoveringASecretDoorRefundsBothCells`. ### A recovered haul spreads across the party: usable items, even wealth, everyone mobile diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 0b4235a..5632c7f 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -699,12 +699,14 @@ class Search(Command): suffices). Each character gets one attempt per cell per kind, ever. A `room_traps` search covers the cell's door edges too: an `open`-trigger trap in an area beyond a known door is findable from this side of it, and a found - trap never springs. An undiscovered secret door hides its trap along with - itself, so discovering one clears that cell's `room_traps` attempts and every - member may search the cell again for the trap the door was hiding. The - referee's [`SetDoorState`][osrlib.crawl.commands.SetDoorState] with - `discovered=True` refunds them the same way. Attempts of other kinds, and - attempts on other cells, stand. + trap never springs. A secret door is one edge shared by two cells, and an + undiscovered one hides its trap along with itself from both sides equally, so + discovering it clears the `room_traps` attempts of both cells the edge joins, + not only the cell the discovery was made from, and every member on either side + may search their cell again for the trap the door was hiding. The referee's + [`SetDoorState`][osrlib.crawl.commands.SetDoorState] with `discovered=True` + refunds them the same way. Attempts of other kinds, and attempts on every other + cell, stand. Modes: `exploring` diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 3bb2a2a..7fbf1e9 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -2117,22 +2117,25 @@ def _handle_search(session, command: Search) -> tuple[list[Rejection], list[Even return [], events -def _refund_trap_search(session, dungeon_id: str, level_number: int, position: Position) -> None: - """Clear one cell's `room_traps` search attempts, so every member may search that cell again. +def _refund_trap_search(session, dungeon_id: str, level_number: int, position: Position, direction: Direction) -> None: + """Clear both cells' `room_traps` search attempts, so every member on either side may search again. - Call this when a secret door on the cell is discovered. A `room_traps` search covers the searched - cell's door edges, and an undiscovered secret door hides the trap beyond it along with itself, so a - member who searched while the door was hidden had no chance at that trap. The discovery is new - information about the cell and their attempt comes back. Attempts of other kinds, and attempts on - other cells, stand. + Call this when the secret door on `position`'s `direction` side is discovered. A secret door is + one edge shared by two cells, and a `room_traps` search from either cell covers that edge: an + undiscovered secret door hides the trap beyond it along with itself, from both sides equally. A + member who searched the far cell while the door still read as wall had no chance at that trap + either, so the discovery refunds both cells, not only the one it was named from. Attempts of + other kinds, and attempts on every other cell, stand. Args: session (osrlib.crawl.session.GameSession): The running session. - dungeon_id: The dungeon the cell is in. + dungeon_id: The dungeon the edge is in. level_number: The 1-based level number. - position: The cell whose attempts are refunded. + position: The cell the discovery was named from. + direction: Which side of `position` the door sits on. The cell across it shares the edge. """ - session.dungeon_state.search_attempts.pop(f"{cell_ref(dungeon_id, level_number, position)}:room_traps", None) + for cell in (position, step(position, direction)): + session.dungeon_state.search_attempts.pop(f"{cell_ref(dungeon_id, level_number, cell)}:room_traps", None) def _reveal(session, kind: str, events: list[Event]) -> list[str]: @@ -2142,16 +2145,20 @@ def _reveal(session, kind: str, events: list[Event]) -> list[str]: state = session.dungeon_state found: list[str] = [] if kind == "secret_doors": + discovered_directions: list[Direction] = [] for direction in Direction: edge = level.edge(position, direction) if edge.kind is EdgeKind.DOOR and edge.door.kind == "secret": if not _door_state(session, direction).discovered: _materialize_door(session, direction).discovered = True found.append(f"secret_door:{direction.value}") - if found: + discovered_directions.append(direction) + dungeon_id, level_number, _ = _dungeon_coords(session) + for direction in discovered_directions: # The door that just appeared may have a trapped area behind it, which was - # unfindable while the door was wall. Everyone gets their trap search back. - _refund_trap_search(session, *_dungeon_coords(session)) + # unfindable while the door was wall, on either of the two cells it joins. + # Everyone on both sides gets their trap search back. + _refund_trap_search(session, dungeon_id, level_number, position, direction) elif kind == "room_traps": # Each candidate carries the door it was found through, or `None` for the # trap in the searcher's own area. The bearing is a fact only this walk has, diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index bfc9510..5f48f03 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -1877,7 +1877,9 @@ def _handle_set_door_state(session: GameSession, command: SetDoorState) -> tuple discovering = secret and command.discovered and not state.discovered state.discovered = command.discovered if discovering: - exploration._refund_trap_search(session, command.dungeon_id, command.level_number, (command.x, command.y)) + exploration._refund_trap_search( + session, command.dungeon_id, command.level_number, (command.x, command.y), command.direction + ) if command.unlocked is not None: state.unlocked = command.unlocked return [], events diff --git a/src/osrlib/persistence.py b/src/osrlib/persistence.py index 4695548..cdde55c 100644 --- a/src/osrlib/persistence.py +++ b/src/osrlib/persistence.py @@ -179,7 +179,10 @@ def _migrate_3_to_4(payload: dict) -> dict: `discovered_features` on the dungeon state, and with them a character's computed `literacy` property, which was never a field and never reached a payload at all. Neither field needs clearing here. The models that read those payloads ignore a key they do not declare, so a - schema 3 save carrying either one loads with the value dropped and nothing else changed. + schema 3 save carrying either one loads with the value dropped and nothing else changed. The + message code that flag's own event once carried is gone too, so an event still naming it would + no longer parse, but no log holds one, because nothing ever set the flag that would have + produced it. """ for entry in payload.get("command_log", ()): if entry.get("command_type") != "resolve_battle_round": From 58f75e0b4ef56ed52cf3bcd8df7f391bd1c18bac Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 19:00:50 -0700 Subject: [PATCH 3/3] Unmark the refund-both-cells acceptance tests Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- tests/test_exploration.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 29e50ed..1117f79 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -1989,7 +1989,6 @@ def searched_far_side_then_stand_near(seed: int) -> GameSession: place(session, (3, 1), facing=Direction.EAST) # inside room_a, the door's other side return session - @pytest.mark.xfail(reason="chunk: refund-both-cells") def test_a_search_from_the_near_side_refunds_the_far_side(self): session = self.searched_far_side_then_stand_near(self.SEED) found = session.execute(Search(character_id="character-0002", kind="secret_doors")) @@ -1998,7 +1997,6 @@ def test_a_search_from_the_near_side_refunds_the_far_side(self): again = session.execute(Search(character_id="character-0001", kind="room_traps")) assert again.accepted, [rejection.code for rejection in again.rejections] - @pytest.mark.xfail(reason="chunk: refund-both-cells") def test_a_referee_discovery_refunds_the_far_side(self): session = self.searched_far_side_then_stand_near(self.SEED) assert session.execute(