Skip to content

Refund the trap search on discovery, fatigue every unrested member, judge a scroll at its own level - #124

Merged
mmacy merged 6 commits into
mainfrom
chunk/exploration-fixes
Sep 15, 2026
Merged

mmacy merged 6 commits into
mainfrom
chunk/exploration-fixes

Conversation

@mmacy

@mmacy mmacy commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

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_traps search 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_traps search attempts, whether the discovery came from a secret_doors search or from the referee's SetDoorState with discovered=True. Nothing else is cleared: secret_doors and construction attempts on that cell, and every attempt on every other cell, stand. The rule is stated in the Search docstring 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 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. The consequence a front end needs is that exploration.trap.safe is 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 the TrapEvent docstring. No behaviour changed, no event changed, and no golden moved.

Fatigue reaches every unrested member (#100)

check_fatigue returned 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 next Rest reset the counter. The check now attaches the effect to each living member who lacks it, emits exploration.fatigue.gained when at least one attachment happened, and returns an empty list when every living member already has it. The Returns: section says so.

A scroll read is judged at the scroll's level (the #117 follow-up)

cast_from_scroll validates and resolves at the spell's minimum caster level, while _use_scroll in crawl/exploration.py and the use_item declaration check in crawl/battle.py built their question from the reading character. A read the crawl accepted could therefore raise ValueError out 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, asks validate_cast about it with profile=None, and returns the rejections cast_from_scroll would raise on. cast_from_scroll runs 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. The cast_from_scroll docstring's Raises: section points at it, and the UseItem rejection 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'] from core/spells.py mid-round, with the scroll already gone. Now the round is refused with magic.cast.target_count, the magic stream is untouched, and the scroll is still in the pack.

Gate

ruff format --check, ruff check, pyright, pytest, and mkdocs build --strict all pass: 2562 passed, 73 skipped, 6 xpassed, and pytest --runxfail tests/test_exploration.py tests/test_magic_items.py tests/test_battle.py tests/test_casting.py is 319 passed. No golden moved, and no draw sequence changed for any call the engine still accepts.

Closes #45, closes #48. Clears the check_fatigue row of #100 and the crawl-side follow-up named in #117.

https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs

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
@mmacy
mmacy merged commit 693e9e4 into main Sep 15, 2026
4 checks passed
@mmacy
mmacy deleted the chunk/exploration-fixes branch September 15, 2026 01:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant