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": diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 40fbd2b..1117f79 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -1971,3 +1971,44 @@ 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 + + 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] + + 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", []) == []