diff --git a/CHANGELOG.md b/CHANGELOG.md index ea1a196..3a45559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ 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. - `commands.json` and `events.json`, the downloadable discriminated-union JSON Schemas under the command and event reference, now describe every property: each one's `description` comes from its attribute docstring, where before pydantic left it blank because it never reads that form (#103). Every `description` in both files, the model's own and each property's, also drops mkdocstrings cross-reference syntax down to the plain name it links, since a schema reaches a reader, or a tool-calling LLM, as text rather than through the site's markdown pipeline; the same cleanup applies to the JSON Schema block on each command's and event's own reference page. The API reference also gains a front page for `osrlib.core` and for `osrlib.crawl`, at the top of each layer's section, rendering the package docstring that a module without its own `__all__` never reached the site with before (#101). - `validate_adventure` now catches two authorable configurations that saved clean but could never fire (#46, #47). An area id colliding with a feature id on the same level is rejected: the two share the trap-reference namespace `"::"` that gates found, sprung, and removed trap state, so an untouched cache trap could read as already sprung because a room trap sharing its id had gone off. An `open`-trigger room trap on an area none of whose boundary edges is a door is rejected too, because opening a door is the only action that springs one and an area with no door edge could never roll for it; a door that `starts_open` still counts, since a referee command or a trigger can close it later. Neither check touches an `enter`-trigger trap, which springs on the party stepping into the area and needs no door at all. - 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. @@ -25,6 +26,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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. 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/docs/adaptations.md b/docs/adaptations.md index 977b70c..85dfb2d 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/core/spells.py b/src/osrlib/core/spells.py index baa189d..b71e356 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 797cde2..547afa5 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,13 +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 - return validate_cast( + # 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/commands.py b/src/osrlib/crawl/commands.py index 0449e87..e3e4fb5 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..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 @@ -713,7 +714,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 +732,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 +782,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 +797,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 +2117,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 +2148,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, @@ -3815,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( + # 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, diff --git a/src/osrlib/crawl/session.py b/src/osrlib/crawl/session.py index 38c20fd..109e53c 100644 --- a/src/osrlib/crawl/session.py +++ b/src/osrlib/crawl/session.py @@ -1872,7 +1872,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 diff --git a/tests/test_battle.py b/tests/test_battle.py index a139fca..8fe29f7 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -1098,3 +1098,58 @@ 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), + ), + ), + ) + + 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..73ce708 100644 --- a/tests/test_casting.py +++ b/tests/test_casting.py @@ -1428,3 +1428,35 @@ 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.""" + + 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 1c435e9..40fbd2b 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,79 @@ 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 + + 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: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")) + assert again.accepted, [rejection.code for rejection in again.rejections] + + 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.""" + + 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..bdbf378 100644 --- a/tests/test_magic_items.py +++ b/tests/test_magic_items.py @@ -657,3 +657,48 @@ 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. + """ + + def test_a_high_level_reader_is_refused_the_missiles_of_their_own_level(self): + from osrlib.core.spells 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]