From feb180424c46ac8d727a662689f0bab05064d992 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 18:10:59 -0700 Subject: [PATCH 1/5] Contract: exploration fixes (chunk exploration-fixes) 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 --- tests/test_exploration.py | 78 +++++++++++++++++++++++++++++++++++++++ tests/test_magic_items.py | 48 ++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 1c435e9..c6546c1 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -45,6 +45,7 @@ RemoveTreasureTrap, Rest, Search, + SetDoorState, TakeTreasure, TravelToTown, TurnParty, @@ -1894,3 +1895,80 @@ def test_a_treasure_share_is_treasure(self): acquired = [event for event in result.events if event.code == "exploration.item.acquired"] assert acquired assert {event.origin for event in acquired} == {"treasure"} + + +class TestDiscoveringASecretDoorRefundsTheCellsTrapSearch: + """Finding a secret door is new information about the cell, so the members who searched the cell + for room traps before the door was known get to search it again. + + A `room_traps` search covers the searched cell's door edges, and an undiscovered secret door hides + the trap beyond it along with itself. Without a refund, a member who searched while the door was + hidden spent their one attempt with no chance at that trap, and once every member had, the trap + could only be learned by opening the door. + """ + + SEED = 3 # the room-trap search by 0001 finds the blade room, then 0002's secret-door search finds the vault's door + + @staticmethod + def before_the_blade_door(seed: int) -> GameSession: + session = GameSession.new(build_party(), build_blade_adventure(), seed=seed) + session.execute(GrantItem(character_id="character-0001", item_id="torch", quantity=6)) + session.execute(GrantItem(character_id="character-0001", item_id="tinder_box")) + entered(session, dungeon_id="blades") + session.execute( + MoveParty(direction=Direction.EAST) + ) # to (1,0): the blade room's door east, the vault's secret door south + return session + + @pytest.mark.xfail(reason="chunk: exploration-fixes") + def test_a_search_that_finds_the_door_refunds_the_room_trap_attempts(self): + session = self.before_the_blade_door(self.SEED) + first = session.execute(Search(character_id="character-0001", kind="room_traps")) + assert "room_trap:blade_room" in next(e for e in first.events if e.code == "exploration.search.found").found + found_door = session.execute(Search(character_id="character-0002", kind="secret_doors")) + assert "secret_door:south" in next(e for e in found_door.events if e.code == "exploration.search.found").found + 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: exploration-fixes") + def test_a_referee_discovery_refunds_them_too(self): + session = self.before_the_blade_door(self.SEED) + assert session.execute(Search(character_id="character-0001", kind="room_traps")).accepted + assert session.execute( + SetDoorState(dungeon_id="blades", level_number=1, x=1, y=0, direction=Direction.SOUTH, discovered=True) + ).accepted + again = session.execute(Search(character_id="character-0001", kind="room_traps")) + assert again.accepted, [rejection.code for rejection in again.rejections] + + def test_other_kinds_and_other_cells_keep_their_attempts(self): + session = self.before_the_blade_door(self.SEED) + assert session.execute(Search(character_id="character-0001", kind="construction")).accepted + assert session.execute( + SetDoorState(dungeon_id="blades", level_number=1, x=1, y=0, direction=Direction.SOUTH, discovered=True) + ).accepted + again = session.execute(Search(character_id="character-0001", kind="construction")) + assert not again.accepted + assert again.rejections[0].code == "exploration.search.already_tried" + + +class TestFatigueReachesEveryUnrestedMember: + """`check_fatigue` attaches the penalty to each living member who lacks it once the party has gone + too long without a rest, not only when nobody has it yet.""" + + @pytest.mark.xfail(reason="chunk: exploration-fixes") + def test_a_member_without_the_effect_gains_it_while_the_others_already_have_it(self): + session = quiet_session() + entered(session) + events, _ = session.advance_turns(6) + assert "exploration.fatigue.gained" in [event.code for event in events] + members = session.party.living_members() + assert all(session.ledger.active_on(member.id, exploration.FATIGUE_KIND) for member in members) + spared = members[1] + for active in session.ledger.active_on(spared.id, exploration.FATIGUE_KIND): + session.ledger.release(active.effect_id, registry=session.registry()) + assert not session.ledger.active_on(spared.id, exploration.FATIGUE_KIND) + gained = exploration.check_fatigue(session) + assert session.ledger.active_on(spared.id, exploration.FATIGUE_KIND) + 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) == [] diff --git a/tests/test_magic_items.py b/tests/test_magic_items.py index 283b566..f072470 100644 --- a/tests/test_magic_items.py +++ b/tests/test_magic_items.py @@ -1,5 +1,7 @@ """The wired magic item census, behavior by behavior — the Phase 5 item contract.""" +import pytest + from crawl_fixtures import build_adventure, build_party from osrlib.core.combat import ( AttackContext, @@ -657,3 +659,49 @@ def test_the_context_bonus_improves_the_defender_ac(self): ) assert shielded.events[0].defender_ac == bare.events[0].defender_ac - 1 assert shielded.required == bare.required + 1 + + +class TestScrollReadsAreJudgedAtTheScrollsLevel: + """The crawl checks a scroll read against a caster at the scroll's level, as the kernel does. + + `cast_from_scroll` validates and resolves at the spell's minimum caster level. The crawl's + own pre-check has to ask about the same caster, or a declaration the crawl accepts raises + from the kernel after the scroll is already spent. A rejected read costs no draw, spends no + scroll, and emits nothing. + """ + + @pytest.mark.xfail(reason="chunk: exploration-fixes") + def test_a_high_level_reader_is_refused_the_missiles_of_their_own_level(self): + from osrlib.crawl.session import MAGIC_STREAM + + session, member, scroll = session_with_item( + "spell_scroll_1", state={"spell_list": "magic_user", "spells": ("magic_missile",)} + ) + member.class_id = "magic_user" + member.level = 6 # three missiles from memory; one off a 1st-level scroll + others = [other.id for other in session.party.members[1:4]] + before = session.streams.get(MAGIC_STREAM).export_state() + refused = session.execute( + UseItem( + character_id=member.id, + item_id=scroll.instance_id, + spell_id="magic_missile", + mode=scroll_mode("magic_missile"), + targets=tuple(others), + ) + ) + assert not refused.accepted + assert [rejection.code for rejection in refused.rejections] == ["magic.cast.target_count"] + assert refused.events == () + assert session.streams.get(MAGIC_STREAM).export_state() == before + assert member.inventory.magic_item(scroll.instance_id) is not None + accepted = session.execute( + UseItem( + character_id=member.id, + item_id=scroll.instance_id, + spell_id="magic_missile", + mode=scroll_mode("magic_missile"), + targets=(others[0],), + ) + ) + assert accepted.accepted, [rejection.code for rejection in accepted.rejections] From 967cd03a4e86ff9273390d9755a94a5ce158e6aa Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 18:22:11 -0700 Subject: [PATCH 2/5] Fix the exploration layer's trap refund, fatigue reach, and scroll level 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 --- CHANGELOG.md | 4 +++ docs/adaptations.md | 2 +- src/osrlib/crawl/battle.py | 4 ++- src/osrlib/crawl/commands.py | 13 +++++-- src/osrlib/crawl/events.py | 12 ++++++- src/osrlib/crawl/exploration.py | 61 ++++++++++++++++++++++++++++----- src/osrlib/crawl/session.py | 6 ++++ 7 files changed, 88 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddf49e3..b85ed40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- The found-trap rule is now pinned rather than inherited, in the adaptations register and on [`TrapEvent`][osrlib.crawl.events.TrapEvent] (#48). 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`][osrlib.crawl.commands.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: a door past a found room trap rolls no die and so has nothing to report, and a known-trap-fails-to-fire beat rendered off that code will never fire at a door. Documentation only; no behaviour, event, or draw sequence changed. - The temple now charges the party for a healing service rather than the patient (#73, part of #108). [`PurchaseHealing`][osrlib.crawl.commands.PurchaseHealing] draws the fee from the treated member's purse first and then from the other members in marching order, dead members included. Each purse pays in whole gold pieces, as much of what is still owed as its gold covers, so a purse that cannot cover the rest hands over all of its gold and keeps what it is worth below a gold piece, and the last purse charged pays the outstanding remainder alone. What the party can spend is therefore the whole gold pieces in its purses rather than their total worth, and a party whose whole gold pieces fall short is refused with `items.purchase.insufficient_funds` and keeps every coin, the funds check staying the pure pre-phase it was. Charging the patient alone made `raise_dead` unbuyable in practice: the patient is a corpse, nothing hands a corpse coin, and a party that splits its treasure never holds 1,500 gp in one purse, so the documented salvage flow (game over, `PlaceParty` to town, raise dead) could not be funded even by a party whose pooled wealth cleared the 1,500 gp fee several times over. [`HealingPurchasedEvent`][osrlib.crawl.events.HealingPurchasedEvent] gains `payers` and `payments_gp`, which name the purses charged and what each one put in, additive within the schema with empty defaults so an older log still parses, and the default English line names the other payers only when the patient could not cover the fee alone. The service names are now one exported type, [`HealingService`][osrlib.crawl.commands.HealingService], which both the command field and the `HEALING_SERVICES` price list use, so the offered list and the accepted list cannot drift apart. No draw sequence changed; the phase 5 milestone golden moved only by the two added event fields. ### Fixed +- Discovering a secret door on a cell now clears that cell's `room_traps` search attempts, so every member may search the cell again (#45). A [`Search`][osrlib.crawl.commands.Search] for `room_traps` 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. 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. The refund fires on a `secret_doors` search that finds the door and on the referee's [`SetDoorState`][osrlib.crawl.commands.SetDoorState] with `discovered=True`, and it clears nothing else: `secret_doors` and `construction` attempts on that cell, and every attempt on every other cell, stand. +- [`check_fatigue`][osrlib.crawl.exploration.check_fatigue] now attaches the unrested-fatigue penalty to each living member who lacks it, not only when nobody in the party has it yet (#100). A member who joined an already-tired party, or who lost the effect some other way, stayed unfatigued beside exhausted companions until the next [`Rest`][osrlib.crawl.commands.Rest] reset the counter. `exploration.fatigue.gained` is emitted when at least one member gained the effect on the call, and the call returns nothing when every living member already carries it, so a party that is fully fatigued still reports the check as quiet. +- A scroll read is now judged at the scroll's caster level by the crawl layer too, in exploration and in battle (#117). [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] validates and resolves at the spell's minimum caster level, while [`UseItem`][osrlib.crawl.commands.UseItem] and the `use_item` battle declaration pre-validated with the reading character, so a read the crawl accepted could raise `ValueError` out of the kernel after the scroll was already spent. Both pre-checks now ask about a caster at the scroll's level, so the crawl refuses exactly what the kernel would refuse, before any state change, draw, or event: a 6th-level magic-user reading a *magic missile* scroll is refused `magic.cast.target_count` for three targets and keeps the scroll. - [`generate_treasure`][osrlib.core.treasure.generate_treasure], [`generate_treasure_entries`][osrlib.core.treasure.generate_treasure_entries], and [`generate_unguarded_treasure`][osrlib.core.treasure.generate_unguarded_treasure] refuse a `tier` that is neither `"basic"` nor `"expert"` before their first draw, the check [`generate_magic_item`][osrlib.core.treasure.generate_magic_item] already made. A bad tier now costs no draws and yields no hoard, where before it could return coins and gems that looked like a generated result. Valid calls draw exactly as before. - [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] judges a read's legality at the scroll's own caster level, the level the spell resolves at, so the two things that scale with caster level follow the scroll rather than the reader: a *magic missile* scroll takes one target however high the reader's level, and a per-level range is measured from the scroll's reach. A high-level reader no longer gets a high-level cast off a 1st-level scroll. diff --git a/docs/adaptations.md b/docs/adaptations.md index 2eeb911..173a427 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. 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. 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`, and `TestTrapResolutionCensus`. +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`. ### A recovered haul spreads across the party: usable items, even wealth, everyone mobile diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index f9edd1d..4b4b754 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1328,8 +1328,10 @@ def _validate_magic_item_declaration(session, declaration: BattleDeclaration, me return rejections from osrlib.core.spells import CastContext + # The read is judged at the scroll's caster level, as the kernel judges it, so a + # declaration that passes here cannot raise out of `cast_from_scroll` in the magic phase. return validate_cast( - member, + exploration._scroll_caster(member, spell), spell, mode, profile=None, diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index a1432ad..e2af543 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -700,7 +700,11 @@ class Search(Command): `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. + 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. Modes: `exploring` @@ -1392,7 +1396,12 @@ class UseItem(Command): (`magic.cast.unknown_target`, `magic.cast.unknown_mode`, `magic.cast.target_count`, `magic.cast.out_of_range`, `magic.cast.caster_incapacitated`, `magic.cast.caster_restrained`, - `magic.cast.anti_magic_shell`). + `magic.cast.anti_magic_shell`). A read is judged against a caster at + the scroll's own level, the level + [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] resolves it + at, so a 6th-level reader of a *magic missile* scroll supplies one + target and is refused three, and the refusal comes before the scroll is + spent. - Devices: `items.device.inert` (no charges left), `items.use.target_required`, `items.use.unknown_target`, and `items.use.battle_only` (a striking effect is a battle declaration). diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index dcf69a8..fd7fa19 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -412,6 +412,16 @@ class TrapEvent(Event): Only its die goes into the referee-visibility [`DetectionRolledEvent`][osrlib.crawl.events.DetectionRolledEvent], so an uneventful step looks like a step on safe ground. + + Finding a trap means two different things by kind, which is what decides where + `exploration.trap.safe` can appear. A found room trap never springs, at its + area's edge or at one of its doors: the party walks around the known pit and + stands clear of the known blade, so a found room trap rolls no die and emits + nothing further. A found treasure trap still rolls its 2-in-6 on every + [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] until a thief takes it out + with [`RemoveTreasureTrap`][osrlib.crawl.commands.RemoveTreasureTrap], because + finding a treasure trap is not defeating it. So `exploration.trap.safe`, a known + trap's trigger resolving without springing, is a cache outcome only. """ allowed_codes: ClassVar[frozenset[str]] = frozenset( @@ -419,7 +429,7 @@ class TrapEvent(Event): ) """`exploration.trap.sprung` when a trap goes off, `.found` when a search or inspection reveals one, `.removed` when a thief disarms one, and `.safe` when a trap the party already - knows about got its chance and didn't fire.""" + knows about got its chance and didn't fire, which only a treasure trap does.""" event_type: Literal["trap"] = "trap" """The wire discriminator, `trap`.""" diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 20ee215..6bf8ff5 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -713,7 +713,7 @@ def check_fatigue(session) -> list[Event]: The SRD's dungeon rule is that a party rests one turn in every six, and a party that presses on takes −1 to attack rolls and −1 to damage rolls until it does. This is the check for that rule: it reads the session's `turns_since_rest` counter, and when the counter has reached the - threshold it attaches the fatigue effect to every living member. + threshold it attaches the fatigue effect to every living member who doesn't already carry it. [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] calls this once for every turn of time the party spends in the field, so a front end that moves time by @@ -731,9 +731,10 @@ def check_fatigue(session) -> list[Event]: Returns: The attachment events, closed by a [`FatigueEvent`][osrlib.crawl.events.FatigueEvent] with - code `exploration.fatigue.gained`. The list is empty before the threshold, and empty when - any living member is already fatigued, so calling this again during the same unrested - stretch attaches nothing. + code `exploration.fatigue.gained`, when at least one member gained the effect on this call. + The list is empty before the threshold, and empty when every living member is already + fatigued, so calling this again during the same unrested stretch attaches nothing. A member + who joins an already-tired party gains the effect on the next call. Examples: ```python @@ -780,11 +781,13 @@ def check_fatigue(session) -> list[Event]: """ if session.turns_since_rest < _fatigue_threshold(session): return [] - living = session.party.living_members() - if any(session.ledger.active_on(member.id, FATIGUE_KIND) for member in living): - return [] events: list[Event] = [] - for member in living: + attached = False + for member in session.party.living_members(): + # Per member, not per party: someone who joined partway through the unrested + # stretch, or who lost the effect some other way, is as tired as everyone else. + if session.ledger.active_on(member.id, FATIGUE_KIND): + continue _, attach_events = session.ledger.attach( _FATIGUE_DEFINITION, member.id, @@ -793,6 +796,9 @@ def check_fatigue(session) -> list[Event]: registry=session.registry(), ) events.extend(attach_events) + attached = True + if not attached: + return [] events.append(FatigueEvent(code="exploration.fatigue.gained")) return events @@ -2110,6 +2116,24 @@ 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. + + 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. + + Args: + session (osrlib.crawl.session.GameSession): The running session. + dungeon_id: The dungeon the cell is in. + level_number: The 1-based level number. + position: The cell whose attempts are refunded. + """ + session.dungeon_state.search_attempts.pop(f"{cell_ref(dungeon_id, level_number, position)}:room_traps", None) + + def _reveal(session, kind: str, events: list[Event]) -> list[str]: """Reveal every hidden feature of one kind on the current cell, and only the cell.""" level = _level(session) @@ -2123,6 +2147,10 @@ def _reveal(session, kind: str, events: list[Event]) -> list[str]: if not _door_state(session, direction).discovered: _materialize_door(session, direction).discovered = True found.append(f"secret_door:{direction.value}") + if found: + # 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)) 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, @@ -3711,6 +3739,21 @@ def _thief_scroll_use(definition) -> dict | None: return None +def _scroll_caster(member, spell): + """The reader as the kernel will judge them: their own body at the scroll's caster level. + + A scroll resolves at the lowest class level able to cast the inscribed spell, and + [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] checks legality at that level too. The + crawl asks [`validate_cast`][osrlib.core.spells.validate_cast] about this caster, so the crawl + refuses exactly what the kernel would refuse, before the scroll is spent. The two checks that + scale with caster level, how many targets a mode demands and how far a per-level range reaches, + therefore follow the scroll rather than the reader. + """ + from osrlib.core.spells import _ScrollReader, minimum_caster_level + + return _ScrollReader(member, minimum_caster_level(spell)) + + def _use_scroll(session, member, instance: MagicItemInstance, template, command) -> tuple[list[Rejection], list]: light_rejections = _requires_light(session, member, infravision_suffices=False) if light_rejections: @@ -3816,7 +3859,7 @@ def _use_scroll(session, member, instance: MagicItemInstance, template, command) return [Rejection(code="magic.cast.unknown_target", params={"target": target_ref})], [] context = _cast_context(session, targets, in_combat=False) cast_rejections = validate_cast( - member, + _scroll_caster(member, spell), spell, mode, profile=None, diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 6a1d409..674e079 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -1870,7 +1870,13 @@ def _handle_set_door_state(session: GameSession, command: SetDoorState) -> tuple if command.wedged is not None: state.wedged = command.wedged if command.discovered is not None: + # A door the referee reveals is discovered the same way a search reveals it, so the + # cell's room-trap searches come back with the trap the door was hiding. + secret = edge.door is not None and edge.door.kind == "secret" + 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)) if command.unlocked is not None: state.unlocked = command.unlocked return [], events From 87a1a7e9a9356f0d438cd7f1641278998b1441cb Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 18:25:17 -0700 Subject: [PATCH 3/5] Contract corrections: the bearing token, the stream constant's home, 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 --- tests/test_battle.py | 58 +++++++++++++++++++++++++++++++++++++++ tests/test_casting.py | 33 ++++++++++++++++++++++ tests/test_exploration.py | 4 ++- tests/test_magic_items.py | 2 +- 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/tests/test_battle.py b/tests/test_battle.py index 90e3a95..be88436 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -1,5 +1,7 @@ """Battle machine tests: rounds, disruption, effect consumption, footprints, morale.""" +import pytest + from crawl_fixtures import build_adventure, build_party from osrlib.core.effects import ActiveCondition, Condition, EffectDefinition, ModifierSpec, has_condition from osrlib.core.events import Visibility @@ -1080,3 +1082,59 @@ def test_an_unidentified_staff_of_striking_stays_a_staff(self): assert staff["display"] == "a staff" assert "qualities" not in staff assert "missile_ranges" not in staff + + +class TestAScrollDeclarationIsJudgedAtTheScrollsLevel: + """The battle pre-check asks about a caster at the scroll's level, as the kernel does, so a round + the engine accepts never raises from the kernel after the scroll is spent.""" + + @staticmethod + def _reader_with_scroll(session): + from osrlib.core.items import MagicItemInstance + + reader = session.member("character-0004") # the magic-user + reader.level = 6 # three missiles from memory; one off a 1st-level scroll + scroll = MagicItemInstance( + instance_id="magic-item-9001", + template_id="spell_scroll_1", + state={"spell_list": "magic_user", "spells": ("magic_missile",)}, + ) + reader.inventory.items.append(scroll) + return reader, scroll + + def _declaration(self, session, scroll, targets): + from osrlib.data import load_spells + + mode = load_spells().get("magic_missile").modes[0].key + return hold_all( + session, + extra=( + BattleDeclaration( + character_id="character-0004", + action="use_item", + item_id=scroll.instance_id, + spell_id="magic_missile", + mode=mode, + targets=targets, + target_group_id=group_id(session), + ), + ), + ) + + @pytest.mark.xfail(reason="chunk: exploration-fixes") + def test_three_targets_are_refused_whole_and_one_is_accepted(self): + from osrlib.core.spells import MAGIC_STREAM + + session = battle_session(count=3, distance=40) + reader, scroll = self._reader_with_scroll(session) + goblins = tuple(session.encounter.groups[0].monster_ids) + assert len(goblins) == 3 + before = session.streams.get(MAGIC_STREAM).export_state() + refused = session.execute(ResolveBattleRound(declarations=self._declaration(session, scroll, goblins))) + assert not refused.accepted + assert "magic.cast.target_count" in [rejection.code for rejection in refused.rejections] + assert session.streams.get(MAGIC_STREAM).export_state() == before + assert reader.inventory.magic_item(scroll.instance_id) is not None + accepted = session.execute(ResolveBattleRound(declarations=self._declaration(session, scroll, goblins[:1]))) + assert accepted.accepted, [rejection.code for rejection in accepted.rejections] + assert reader.inventory.magic_item(scroll.instance_id) is None diff --git a/tests/test_casting.py b/tests/test_casting.py index c4de165..6bdd757 100644 --- a/tests/test_casting.py +++ b/tests/test_casting.py @@ -1428,3 +1428,36 @@ def test_a_high_level_reader_supplies_the_scrolls_missile_count(self): assert streams.get(MAGIC_STREAM).export_state() == before outcome = cast_from_scroll(zelia, missile, "missiles", targets=goblins[:1], **common) assert outcome.affected_ids == ("monster-0001",) + + +class TestValidateScrollCastIsTheKernelsOwnPreCheck: + """`validate_scroll_cast` answers what `cast_from_scroll` will refuse, at the scroll's caster + level, so a front end or the crawl layer never rebuilds the scroll-level caster itself.""" + + @pytest.mark.xfail(reason="chunk: exploration-fixes") + def test_it_refuses_the_readers_missile_count_and_takes_the_scrolls(self): + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.monsters import MONSTER_SPAWN_STREAM + from osrlib.core.spells import validate_scroll_cast + from osrlib.data import load_monsters, load_spells + + streams = RngStreams(master_seed=5) + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["read_magic"], + ).character + zelia.level = 6 + template = load_monsters().get("goblin") + goblins = [ + spawn_monster(template, id=f"monster-000{number}", stream=streams.get(MONSTER_SPAWN_STREAM)) + for number in (1, 2, 3) + ] + missile = load_spells().get("magic_missile") + refused = validate_scroll_cast(zelia, missile, "missiles", targets=goblins) + assert [rejection.code for rejection in refused] == ["magic.cast.target_count"] + assert refused[0].params["expected"] == 1 + assert validate_scroll_cast(zelia, missile, "missiles", targets=goblins[:1]) == [] diff --git a/tests/test_exploration.py b/tests/test_exploration.py index c6546c1..9e1933a 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -1924,7 +1924,9 @@ def before_the_blade_door(seed: int) -> GameSession: def test_a_search_that_finds_the_door_refunds_the_room_trap_attempts(self): session = self.before_the_blade_door(self.SEED) first = session.execute(Search(character_id="character-0001", kind="room_traps")) - assert "room_trap:blade_room" in next(e for e in first.events if e.code == "exploration.search.found").found + assert ( + "room_trap:blade_room:east" in next(e for e in first.events if e.code == "exploration.search.found").found + ) found_door = session.execute(Search(character_id="character-0002", kind="secret_doors")) assert "secret_door:south" in next(e for e in found_door.events if e.code == "exploration.search.found").found again = session.execute(Search(character_id="character-0001", kind="room_traps")) diff --git a/tests/test_magic_items.py b/tests/test_magic_items.py index f072470..c6e0f6f 100644 --- a/tests/test_magic_items.py +++ b/tests/test_magic_items.py @@ -672,7 +672,7 @@ class TestScrollReadsAreJudgedAtTheScrollsLevel: @pytest.mark.xfail(reason="chunk: exploration-fixes") def test_a_high_level_reader_is_refused_the_missiles_of_their_own_level(self): - from osrlib.crawl.session import MAGIC_STREAM + from osrlib.core.spells import MAGIC_STREAM session, member, scroll = session_with_item( "spell_scroll_1", state={"spell_list": "magic_user", "spells": ("magic_missile",)} From 30c5cc9c13cae2396df4a1e9a1b82bc21717d083 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 18:29:48 -0700 Subject: [PATCH 4/5] Give the kernel a public scroll pre-check 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 --- CHANGELOG.md | 2 +- src/osrlib/core/spells.py | 114 +++++++++++++++++++++++++++++--- src/osrlib/crawl/battle.py | 13 ++-- src/osrlib/crawl/exploration.py | 24 ++----- 4 files changed, 118 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b85ed40..9a827b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Discovering a secret door on a cell now clears that cell's `room_traps` search attempts, so every member may search the cell again (#45). A [`Search`][osrlib.crawl.commands.Search] for `room_traps` 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. 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. The refund fires on a `secret_doors` search that finds the door and on the referee's [`SetDoorState`][osrlib.crawl.commands.SetDoorState] with `discovered=True`, and it clears nothing else: `secret_doors` and `construction` attempts on that cell, and every attempt on every other cell, stand. - [`check_fatigue`][osrlib.crawl.exploration.check_fatigue] now attaches the unrested-fatigue penalty to each living member who lacks it, not only when nobody in the party has it yet (#100). A member who joined an already-tired party, or who lost the effect some other way, stayed unfatigued beside exhausted companions until the next [`Rest`][osrlib.crawl.commands.Rest] reset the counter. `exploration.fatigue.gained` is emitted when at least one member gained the effect on the call, and the call returns nothing when every living member already carries it, so a party that is fully fatigued still reports the check as quiet. -- A scroll read is now judged at the scroll's caster level by the crawl layer too, in exploration and in battle (#117). [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] validates and resolves at the spell's minimum caster level, while [`UseItem`][osrlib.crawl.commands.UseItem] and the `use_item` battle declaration pre-validated with the reading character, so a read the crawl accepted could raise `ValueError` out of the kernel after the scroll was already spent. Both pre-checks now ask about a caster at the scroll's level, so the crawl refuses exactly what the kernel would refuse, before any state change, draw, or event: a 6th-level magic-user reading a *magic missile* scroll is refused `magic.cast.target_count` for three targets and keeps the scroll. +- A scroll read is now judged at the scroll's caster level by the crawl layer too, in exploration and in battle (#117). [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] validates and resolves at the spell's minimum caster level, while [`UseItem`][osrlib.crawl.commands.UseItem] and the `use_item` battle declaration pre-validated with the reading character, so a read the crawl accepted could raise `ValueError` out of the kernel after the scroll was already spent. The kernel now exports the pre-check itself as [`validate_scroll_cast`][osrlib.core.spells.validate_scroll_cast], which builds the scroll-level caster and returns the rejections `cast_from_scroll` would raise on; `cast_from_scroll` runs it, and so do both crawl pre-checks, so there is one statement of the rule and a front end can ask it too. The crawl therefore refuses exactly what the kernel would refuse, before any state change, draw, or event: a 6th-level magic-user reading a *magic missile* scroll is refused `magic.cast.target_count` for three targets and keeps the scroll. - [`generate_treasure`][osrlib.core.treasure.generate_treasure], [`generate_treasure_entries`][osrlib.core.treasure.generate_treasure_entries], and [`generate_unguarded_treasure`][osrlib.core.treasure.generate_unguarded_treasure] refuse a `tier` that is neither `"basic"` nor `"expert"` before their first draw, the check [`generate_magic_item`][osrlib.core.treasure.generate_magic_item] already made. A bad tier now costs no draws and yields no hoard, where before it could return coins and gems that looked like a generated result. Valid calls draw exactly as before. - [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] judges a read's legality at the scroll's own caster level, the level the spell resolves at, so the two things that scale with caster level follow the scroll rather than the reader: a *magic missile* scroll takes one target however high the reader's level, and a per-level range is measured from the scroll's reach. A high-level reader no longer gets a high-level cast off a 1st-level scroll. diff --git a/src/osrlib/core/spells.py b/src/osrlib/core/spells.py index 885d1b0..619b22b 100644 --- a/src/osrlib/core/spells.py +++ b/src/osrlib/core/spells.py @@ -34,8 +34,10 @@ [`validate_cast`][osrlib.core.spells.validate_cast] checks legality and [`cast_spell`][osrlib.core.spells.cast_spell] consumes the memorized copy and resolves the mode. [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] resolves an inscribed spell with no -memorized copy behind it, and [`disrupt_casting`][osrlib.core.spells.disrupt_casting] loses a copy -when a declared cast is broken. Clerics also turn undead here: +memorized copy behind it, checked first by +[`validate_scroll_cast`][osrlib.core.spells.validate_scroll_cast], and +[`disrupt_casting`][osrlib.core.spells.disrupt_casting] loses a copy when a declared cast is +broken. Clerics also turn undead here: [`validate_turn_undead`][osrlib.core.spells.validate_turn_undead], then [`turn_undead`][osrlib.core.spells.turn_undead]. @@ -197,6 +199,7 @@ "pop_mirror_image", "turn_undead", "validate_cast", + "validate_scroll_cast", "validate_turn_undead", ] @@ -2294,6 +2297,101 @@ def minimum_caster_level(spell: SpellTemplate) -> int: return best +def validate_scroll_cast( + reader: Any, + spell: SpellTemplate, + mode: str, + *, + reversed: bool = False, + targets: Sequence[object] | None = None, + context: CastContext | None = None, + ledger: EffectsLedger | None = None, +) -> list[Rejection]: + """Ask whether a scroll read is legal, without reading it. + + This is [`validate_cast`][osrlib.core.spells.validate_cast] for a spell coming off a page, and it + is the check [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] itself makes. Call it to + decide whether to offer a read, and call it before any read you are about to make, because + `cast_from_scroll` raises on an illegal read and the scroll is your own to spend: a refusal you + saw first costs nothing, and one you did not costs the scroll. + + The difference from `validate_cast` is the caster the question is asked about. A scroll resolves + at the lowest class level able to cast the inscribed spell, from + [`minimum_caster_level`][osrlib.core.spells.minimum_caster_level], whatever level the reader is, + so this builds that caster and asks about them. The two checks that scale with caster level + therefore follow the scroll: how many targets a mode demands, which is why a 6th-level reader of + a *magic missile* scroll supplies one target and is refused three, and how far a per-level range + reaches. The memorized-copy check is skipped, since the scroll is the copy. + + Two things it does not answer, because they are not the kernel's to judge: whether this reader + may read this scroll at all, which is where a thief's scroll-use ability and the arcane and + divine divide come in, and whether there is light to read by. The crawl layer checks both. + + Args: + reader: The character reading the scroll, a + [`Character`][osrlib.core.character.Character]. Read, never written. + spell: The inscribed [`SpellTemplate`][osrlib.core.spells.SpellTemplate], from + [`SpellCatalog.get`][osrlib.core.spells.SpellCatalog.get]. + mode: Which usage of the spell, by its [`SpellMode.key`][osrlib.core.spells.SpellMode]. A key + the chosen form does not have is a rejection, not an exception. + reversed: True to ask about the spell's reversed form. + targets: The candidate targets, per the combatant convention (see + [`osrlib.core.combat`][osrlib.core.combat]). Only the count is examined. `None` means no + targets, the same as an empty sequence. + context: The [`CastContext`][osrlib.core.spells.CastContext] with what you assert about the + situation. `None` asserts nothing. + ledger: The [`EffectsLedger`][osrlib.core.effects.EffectsLedger], consulted for effects on the + reader that block casting. Pass `None` and no such effect is found, so pass the ledger you + play with. + + Returns: + Every reason the read is illegal, as [`Rejection`][osrlib.core.validation.Rejection] models + with structured `code` and `params`. Empty when the read may go ahead, which means + `cast_from_scroll` with the same arguments will not raise. + + Examples: + ```python + from osrlib.core.alignment import Alignment + from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character + from osrlib.core.monsters import MONSTER_SPAWN_STREAM, spawn_monster + from osrlib.core.rng import RngStreams + from osrlib.core.ruleset import Ruleset + from osrlib.core.spells import validate_scroll_cast + from osrlib.data import load_monsters, load_spells + + streams = RngStreams(master_seed=5) + zelia = create_character( + name="Zelia", + class_id="magic_user", + alignment=Alignment.NEUTRAL, + ruleset=Ruleset(), + stream=streams.get(CHARACTER_CREATION_STREAM), + starting_spell_ids=["read_magic"], + ).character + zelia.level = 6 # three missiles from memory, one off a 1st-level scroll + template = load_monsters().get("goblin") + spawns = streams.get(MONSTER_SPAWN_STREAM) + goblins = [spawn_monster(template, id=f"monster-000{number}", stream=spawns) for number in (1, 2, 3)] + missile = load_spells().get("magic_missile") + + refused = validate_scroll_cast(zelia, missile, "missiles", targets=goblins) + assert [rejection.code for rejection in refused] == ["magic.cast.target_count"] + assert refused[0].params["expected"] == 1 # the scroll's level, not the reader's + assert validate_scroll_cast(zelia, missile, "missiles", targets=goblins[:1]) == [] + ``` + """ + return validate_cast( + _ScrollReader(reader, minimum_caster_level(spell)), + spell, + mode, + profile=None, + reversed=reversed, + targets=() if targets is None else targets, + context=context, + ledger=ledger, + ) + + def cast_from_scroll( reader: Any, spell: SpellTemplate, @@ -2374,10 +2472,9 @@ def cast_from_scroll( Raises: ValueError: If the read is illegal. Nothing is drawn or changed before the refusal. Ask - [`validate_cast`][osrlib.core.spells.validate_cast] with `profile=None` first to get - the reasons instead of the exception, which is also what tells it to skip the - memorized-copy check, and ask it about a caster at the scroll's level so its answer - matches this one. + [`validate_scroll_cast`][osrlib.core.spells.validate_scroll_cast] first to get the + reasons instead of the exception. It is the check this call makes, asked the same way, + so an empty answer from it means this call will not raise. Examples: ```python @@ -2427,11 +2524,10 @@ def cast_from_scroll( """ context = context or CastContext() caster = _ScrollReader(reader, minimum_caster_level(spell)) - rejections = validate_cast( - caster, + rejections = validate_scroll_cast( + reader, spell, mode, - profile=None, reversed=reversed, targets=targets, context=context, diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 4b4b754..2753f3f 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1308,7 +1308,7 @@ def _validate_magic_item_declaration(session, declaration: BattleDeclaration, me if spell_id not in remaining: return [Rejection(code="items.scroll.no_such_spell", params={"spell": spell_id})] definition = load_classes().get(member.class_id) - from osrlib.core.spells import caster_profile, validate_cast + from osrlib.core.spells import caster_profile profile = caster_profile(definition) divine_scroll = instance.state.get("spell_list") == "cleric" @@ -1326,15 +1326,14 @@ def _validate_magic_item_declaration(session, declaration: BattleDeclaration, me ) if rejections: return rejections - from osrlib.core.spells import CastContext + from osrlib.core.spells import CastContext, validate_scroll_cast - # The read is judged at the scroll's caster level, as the kernel judges it, so a - # declaration that passes here cannot raise out of `cast_from_scroll` in the magic phase. - return validate_cast( - exploration._scroll_caster(member, spell), + # The kernel's own pre-check, at the scroll's caster level, so a declaration that + # passes here cannot raise out of `cast_from_scroll` in the magic phase. + return validate_scroll_cast( + member, spell, mode, - profile=None, targets=targets, context=CastContext(in_combat=True, distance_feet=distance), ledger=session.ledger, diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 6bf8ff5..5cfd831 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -126,6 +126,7 @@ class up in [`HANDLERS`][osrlib.crawl.exploration.HANDLERS] and runs the handler caster_profile, memorize_spells, validate_cast, + validate_scroll_cast, ) from osrlib.core.tables import select_encounter_individuals from osrlib.core.validation import Rejection @@ -3739,21 +3740,6 @@ def _thief_scroll_use(definition) -> dict | None: return None -def _scroll_caster(member, spell): - """The reader as the kernel will judge them: their own body at the scroll's caster level. - - A scroll resolves at the lowest class level able to cast the inscribed spell, and - [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] checks legality at that level too. The - crawl asks [`validate_cast`][osrlib.core.spells.validate_cast] about this caster, so the crawl - refuses exactly what the kernel would refuse, before the scroll is spent. The two checks that - scale with caster level, how many targets a mode demands and how far a per-level range reaches, - therefore follow the scroll rather than the reader. - """ - from osrlib.core.spells import _ScrollReader, minimum_caster_level - - return _ScrollReader(member, minimum_caster_level(spell)) - - def _use_scroll(session, member, instance: MagicItemInstance, template, command) -> tuple[list[Rejection], list]: light_rejections = _requires_light(session, member, infravision_suffices=False) if light_rejections: @@ -3858,11 +3844,13 @@ def _use_scroll(session, member, instance: MagicItemInstance, template, command) else: return [Rejection(code="magic.cast.unknown_target", params={"target": target_ref})], [] context = _cast_context(session, targets, in_combat=False) - cast_rejections = validate_cast( - _scroll_caster(member, spell), + # The kernel's own pre-check: a caster at the scroll's level, which is the level + # `cast_from_scroll` resolves at, so the crawl refuses what the kernel would refuse + # and the refusal lands before the scroll is spent. + cast_rejections = validate_scroll_cast( + member, spell, mode, - profile=None, targets=targets, context=context, ledger=session.ledger, From ebd26dbbeb85184cd1508dd712aca1e565b90989 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 18:36:19 -0700 Subject: [PATCH 5/5] Unmark the exploration-fixes acceptance tests Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- tests/test_battle.py | 1 - tests/test_casting.py | 1 - tests/test_exploration.py | 3 --- tests/test_magic_items.py | 3 --- 4 files changed, 8 deletions(-) diff --git a/tests/test_battle.py b/tests/test_battle.py index 6395b1a..8fe29f7 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -1137,7 +1137,6 @@ def _declaration(self, session, scroll, targets): ), ) - @pytest.mark.xfail(reason="chunk: exploration-fixes") def test_three_targets_are_refused_whole_and_one_is_accepted(self): from osrlib.core.spells import MAGIC_STREAM diff --git a/tests/test_casting.py b/tests/test_casting.py index 6bdd757..73ce708 100644 --- a/tests/test_casting.py +++ b/tests/test_casting.py @@ -1434,7 +1434,6 @@ class TestValidateScrollCastIsTheKernelsOwnPreCheck: """`validate_scroll_cast` answers what `cast_from_scroll` will refuse, at the scroll's caster level, so a front end or the crawl layer never rebuilds the scroll-level caster itself.""" - @pytest.mark.xfail(reason="chunk: exploration-fixes") def test_it_refuses_the_readers_missile_count_and_takes_the_scrolls(self): from osrlib.core.character import CHARACTER_CREATION_STREAM, create_character from osrlib.core.monsters import MONSTER_SPAWN_STREAM diff --git a/tests/test_exploration.py b/tests/test_exploration.py index 9e1933a..40fbd2b 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -1920,7 +1920,6 @@ def before_the_blade_door(seed: int) -> GameSession: ) # to (1,0): the blade room's door east, the vault's secret door south return session - @pytest.mark.xfail(reason="chunk: exploration-fixes") def test_a_search_that_finds_the_door_refunds_the_room_trap_attempts(self): session = self.before_the_blade_door(self.SEED) first = session.execute(Search(character_id="character-0001", kind="room_traps")) @@ -1932,7 +1931,6 @@ def test_a_search_that_finds_the_door_refunds_the_room_trap_attempts(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: exploration-fixes") def test_a_referee_discovery_refunds_them_too(self): session = self.before_the_blade_door(self.SEED) assert session.execute(Search(character_id="character-0001", kind="room_traps")).accepted @@ -1957,7 +1955,6 @@ class TestFatigueReachesEveryUnrestedMember: """`check_fatigue` attaches the penalty to each living member who lacks it once the party has gone too long without a rest, not only when nobody has it yet.""" - @pytest.mark.xfail(reason="chunk: exploration-fixes") def test_a_member_without_the_effect_gains_it_while_the_others_already_have_it(self): session = quiet_session() entered(session) diff --git a/tests/test_magic_items.py b/tests/test_magic_items.py index c6e0f6f..bdbf378 100644 --- a/tests/test_magic_items.py +++ b/tests/test_magic_items.py @@ -1,7 +1,5 @@ """The wired magic item census, behavior by behavior — the Phase 5 item contract.""" -import pytest - from crawl_fixtures import build_adventure, build_party from osrlib.core.combat import ( AttackContext, @@ -670,7 +668,6 @@ class TestScrollReadsAreJudgedAtTheScrollsLevel: scroll, and emits nothing. """ - @pytest.mark.xfail(reason="chunk: exploration-fixes") def test_a_high_level_reader_is_refused_the_missiles_of_their_own_level(self): from osrlib.core.spells import MAGIC_STREAM