Repository navigation
Refund the trap search on discovery, fatigue every unrested member, judge a scroll at its own level - #124
Merged
Conversation
Acceptance tests marked expected-to-fail: discovering a secret door, by search or by referee command, refunds the cell's room-trap search attempts; the fatigue check reaches every unrested member; and the crawl judges a scroll read at the scroll's caster level, refusing before the scroll is spent. An unmarked guard holds that other search kinds keep their attempts. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs
Four fixes that share crawl/exploration.py. Discovering a secret door on a cell clears that cell's room_traps search attempts, by a secret_doors search or by the referee's SetDoorState with discovered=True. 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 wall spent their one attempt with no chance at that trap, and once every member had, the trap the door-trap rule promises is findable could only be learned by opening the door. Other kinds and other cells keep their attempts. check_fatigue attaches the penalty to each living member who lacks it, rather than returning early when any living member already carries it, so a member who joined an already-tired party is tired too. It emits exploration.fatigue.gained when at least one attachment happened and returns nothing when every living member already has the effect. The crawl judges a scroll read at the scroll's caster level, in exploration and in battle, the level cast_from_scroll validates and resolves at. Both pre-checks built their question from the reading character, so a read the crawl accepted could raise ValueError out of the kernel after the scroll was spent: a 6th-level magic-user reading a magic missile scroll passed the crawl with three targets and then crashed the round. The refusal now comes before any state change, draw, or event. The found-trap split is pinned in the adaptations register and on TrapEvent rather than left inherited: a found room trap never springs, at an area's edge or at a door, while a found treasure trap still rolls its spring die on TakeTreasure until a thief removes it, so exploration.trap.safe is a cache-only outcome. That part is documentation; no behaviour changed and no golden moved. Two of the chunk's four acceptance tests stay marked expected-to-fail because their own text is wrong, not the implementation: tests/test_exploration.py:1927 asserts the found token "room_trap:blade_room" where the blade room is reached through (1,0)'s east door, so the token is "room_trap:blade_room:east" as tests/test_exploration.py:1871 locks it, and tests/test_magic_items.py:675 imports MAGIC_STREAM from osrlib.crawl.session, which has never defined it (it lives in osrlib.core.spells, where every other test imports it). Both pass with only those two corrections. Closes #45, closes #48. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs
…and the scroll pre-check (chunk exploration-fixes) Two assertions in the contract were wrong: the refund test read the door-find token without its bearing segment, and the scroll test imported MAGIC_STREAM from the crawl session instead of core.spells. The battle path gains its own scroll test, and a marked test asks the kernel for a public validate_scroll_cast so the crawl stops importing the private proxy. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs
validate_scroll_cast builds the caster at the scroll's level and asks validate_cast about them with profile=None, returning the rejections cast_from_scroll would raise on. cast_from_scroll runs it, and so do both crawl pre-checks, _use_scroll in exploration.py and the scroll branch of the battle declaration check, so the scroll-level rule has one statement and the crawl no longer reaches into the kernel for the private _ScrollReader proxy. A front end that wants to offer or grey out a read can now ask the same question. cast_from_scroll's Raises: section points callers at the new function, the module's daily-flow paragraph names it beside cast_from_scroll, and the changelog bullet for the scroll fix says the kernel exports it. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs
# Conflicts: # CHANGELOG.md # tests/test_battle.py
This was referenced Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four fixes in the exploration layer, one branch because they share
crawl/exploration.py, plus the kernel pre-check the fourth one needed.Refund on discovery (#45)
A
room_trapssearch covers the searched cell's door edges, and an undiscovered secret door hides the trap beyond it along with itself. A member who searched while the door was still wall therefore spent their one attempt with no chance at that trap, and once every member had spent theirs the trap the door-trap rule promises is findable could only be learned by opening the door and taking the 2-in-6.Discovering a secret door on a cell now clears that cell's
room_trapssearch attempts, whether the discovery came from asecret_doorssearch or from the referee'sSetDoorStatewithdiscovered=True. Nothing else is cleared:secret_doorsandconstructionattempts on that cell, and every attempt on every other cell, stand. The rule is stated in theSearchdocstring and in the adaptations register's searching entry.The refund covers the cell the discovery names. A party that searched the far side of the same door keeps its stale rejection, which is worth a follow-up issue if you want the rule to be symmetric.
The found-trap split, pinned (#48)
Documentation only. A found room trap never springs, at its area's edge or at one of its doors, while a found treasure trap still rolls its 2-in-6 on every
TakeTreasureuntil 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. The consequence a front end needs is thatexploration.trap.safeis a cache outcome only, so a known-trap-fails-to-fire beat rendered off that code will never fire at a door. Recorded in the adaptations register and in theTrapEventdocstring. No behaviour changed, no event changed, and no golden moved.Fatigue reaches every unrested member (#100)
check_fatiguereturned early when any living member already carried the effect, so a member who joined the party partway through an unrested stretch stayed unfatigued beside exhausted companions until the nextRestreset the counter. The check now attaches the effect to each living member who lacks it, emitsexploration.fatigue.gainedwhen at least one attachment happened, and returns an empty list when every living member already has it. TheReturns:section says so.A scroll read is judged at the scroll's level (the #117 follow-up)
cast_from_scrollvalidates and resolves at the spell's minimum caster level, while_use_scrollincrawl/exploration.pyand theuse_itemdeclaration check incrawl/battle.pybuilt their question from the reading character. A read the crawl accepted could therefore raiseValueErrorout of the kernel after the scroll was already spent.The kernel now exports the pre-check itself.
validate_scroll_cast(reader, spell, mode, *, reversed, targets, context, ledger)builds the scroll-level caster, asksvalidate_castabout it withprofile=None, and returns the rejectionscast_from_scrollwould raise on.cast_from_scrollruns it, and so do both crawl pre-checks, so the scroll-level rule has one statement, the crawl holds no copy of it and no private import of the kernel's proxy, and a front end deciding whether to offer a read can ask the same question. Thecast_from_scrolldocstring'sRaises:section points at it, and theUseItemrejection list states the rule for command callers.Before the change, a battle round in which a 6th-level magic-user declared three missiles off a 1st-level magic missile scroll raised
ValueError: illegal scroll cast: ['magic.cast.target_count']fromcore/spells.pymid-round, with the scroll already gone. Now the round is refused withmagic.cast.target_count, the magic stream is untouched, and the scroll is still in the pack.Gate
ruff format --check,ruff check,pyright,pytest, andmkdocs build --strictall pass: 2562 passed, 73 skipped, 6 xpassed, andpytest --runxfail tests/test_exploration.py tests/test_magic_items.py tests/test_battle.py tests/test_casting.pyis 319 passed. No golden moved, and no draw sequence changed for any call the engine still accepts.Closes #45, closes #48. Clears the
check_fatiguerow of #100 and the crawl-side follow-up named in #117.https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs