Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adaptations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 8 additions & 6 deletions src/osrlib/crawl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
33 changes: 20 additions & 13 deletions src/osrlib/crawl/exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/osrlib/crawl/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/osrlib/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
41 changes: 41 additions & 0 deletions tests/test_exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", []) == []
Loading