From e4ff569b78c57cab21fcb22e51efbec5b2207c08 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:34:01 -0700 Subject: [PATCH 1/3] Judge a battle declaration again in the magic phase (#129) A declaration passed its checks when the round was accepted, and the magic phase resolved it without asking again, so anything the earlier phases changed reached the kernel unjudged: cast_spell raised ValueError out of the round, or the spell went off when the rules said it could not. The reachable case is silence 15' radius. A creature that passes its save leaves the area anchored on the party's cell, so an ally's silence that resolves earlier in the same magic phase silences a caster whose declaration was accepted with no silence on the cell, and the caster cast anyway. _party_magic now re-runs _validate_declaration for a cast, and _resolve_scroll_cast re-runs _validate_magic_item_declaration for a scroll read, immediately before the declaration resolves and before anything is spent or drawn. A refused declaration is not cast and never raises: the caster loses the memorized copy through disrupt_casting, or the scroll its spell as a read would, concentration releases, the caster counts as having acted, and the round reports it with SpellDisruptedEvent at the new code magic.cast.fizzled. Its new reason field holds the first rejection code the re-check produced. A scroll read that fizzles emits items.scroll.read first, because the reader did read it. The disruption path is untouched. An accepted declaration takes the same draws in the same order, because both re-checks are pure validators that resolve before the first draw. Two goldens move by one line each and nothing else: reason defaults None, so the key serializes into every dumped SpellDisruptedEvent. The phase 4 delve golden's engine_version stamp is deliberately left at the version that produced it. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- CHANGELOG.md | 1 + docs/adaptations.md | 2 +- src/osrlib/core/events.py | 33 ++++++++++++---- src/osrlib/crawl/battle.py | 52 +++++++++++++++++++++++--- src/osrlib/messages.py | 5 +++ tests/goldens/phase3_spell_battle.json | 1 + tests/goldens/phase4_delve.json | 1 + 7 files changed, 81 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5fc2d..0c2e940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Discovering a secret door clears the `room_traps` search attempts of both cells of that edge, so every member may search either cell again (#45, #129). 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 searches while the door is still wall spends their attempt with no chance at that trap. 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. The refund reaches the far cell too, whichever side the discovery came from, because a search from either cell covers the same edge. - [`check_fatigue`][osrlib.crawl.exploration.check_fatigue] attaches the unrested-fatigue penalty to each living member who lacks it, so a member who joins an already-tired party, or who loses the effect some other way, is fatigued beside their companions rather than waiting for the next [`Rest`][osrlib.crawl.commands.Rest] to reset the counter (#100). `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 fully fatigued party reports the check as quiet. +- A battle declaration is judged again in the magic phase, immediately before it resolves, so a cast the rules can no longer allow fizzles instead of going off or raising out of the round (#129). The declaration passes its checks when the round is accepted, and the phases before the magic phase can change what those checks read: the reachable case is *silence 15' radius*, where a creature that passes its save leaves the area anchored on the party's cell, so an ally's silence resolving earlier in the same magic phase silences a caster whose declaration was accepted with no silence on the cell. [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] now re-runs the same checks a `cast` declaration and a scroll-read `use_item` declaration passed, against the state as it stands then and before anything is spent or drawn. A refused one is not cast: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with a [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] at the new code `magic.cast.fizzled`, whose new `reason` field holds the first rejection code the re-check produced, `magic.cast.silenced_area` for the silence. `reason` defaults `None`, so a log written before the field existed still parses, and a `magic.cast.disrupted` event leaves it unset. A scroll read that fizzles emits its `items.scroll.read` event first, because the reader did read it. Match on `reason` the way you match on a rejection's own code. A declaration the re-check accepts resolves as it always did, with the same draws in the same order. - A scroll read is judged at the scroll's own caster level, in the kernel and in the crawl layer alike (#117). [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] validates and resolves at the spell's minimum caster level, 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. The kernel 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 [`UseItem`][osrlib.crawl.commands.UseItem] and the `use_item` battle declaration, so there is one statement of the rule and a front end deciding whether to offer a read 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] raise `ValueError` for a `tier` that is neither `"basic"` nor `"expert"` before their first draw, the check [`generate_magic_item`][osrlib.core.treasure.generate_magic_item] already makes. A bad tier costs no draws and yields no hoard, so a caller who passes a typo sees the bug rather than a plausible pile of coins and gems. A valid call's draw sequence is unchanged. diff --git a/docs/adaptations.md b/docs/adaptations.md index f0c21fd..f14bc66 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -340,7 +340,7 @@ Pursuit compares the party's slowest running rate (full feet per round) against ### The battle round -Battle declarations arrive as one command per round with one declaration per living, able member, rejected whole on any invalid declaration (partial acceptance would tangle the replay contract). Formation width is the frontage the party's own space offers under `formation_width_limit`: the widest square of unbroken floor that includes the party's cell, at five feet of frontage each — RAW's "2–3 characters in a 10' passage" pinned to its conservative end and then applied to whatever space the party stands in, so a ten-foot passage holds two however long it runs and a room holds its shorter side. Ranks recompute from the living marching order at round start and melee reaches the front rank only, both directions, with no firing-into-melee penalty (none exists in the SRD). The party moves as a formation — individual members cannot leave it (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate"; the running pursuit begins when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A `fighting_withdrawal` or a `retreat` that not every declarer chose is therefore not a legal declaration for that member: the round is refused whole with `battle.declaration.formation_split`, one rejection per defensive move the round split on, naming the declarers who chose it and the declarers who did not. `close` is not a defensive move and needs no agreement: a lone `close` advances the formation, and a `close` declared beside a `fighting_withdrawal` or a `retreat` counts among the others that split the round, so that round is refused and nothing advances. A round whose only declarer declares a defensive move is legal, because everyone agreed. A fighting withdrawal here is a pure move and pinned as one: RAW gives the withdrawing combatant the backward move and takes nothing away, so the attack stays with them, but a member declares one thing per round and the whole formation moves together, so a round the party withdraws in is a round it does not attack in. The SRD's attack-while-withdrawing half is not represented. The move set is the SRD's two defensive moves and `close`, with no fourth. Retiring the fourth value is schema version 4, with a lossless migration that clears it off a logged declaration and leaves a member who declared it holding, as that round played out. A party melee attack lands on the first living, visible monster of the group's reachable rank (deterministic, no draw); monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted — a class ability, not a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, and the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`. +Battle declarations arrive as one command per round with one declaration per living, able member, rejected whole on any invalid declaration (partial acceptance would tangle the replay contract). Formation width is the frontage the party's own space offers under `formation_width_limit`: the widest square of unbroken floor that includes the party's cell, at five feet of frontage each — RAW's "2–3 characters in a 10' passage" pinned to its conservative end and then applied to whatever space the party stands in, so a ten-foot passage holds two however long it runs and a room holds its shorter side. Ranks recompute from the living marching order at round start and melee reaches the front rank only, both directions, with no firing-into-melee penalty (none exists in the SRD). The party moves as a formation — individual members cannot leave it (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate"; the running pursuit begins when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A `fighting_withdrawal` or a `retreat` that not every declarer chose is therefore not a legal declaration for that member: the round is refused whole with `battle.declaration.formation_split`, one rejection per defensive move the round split on, naming the declarers who chose it and the declarers who did not. `close` is not a defensive move and needs no agreement: a lone `close` advances the formation, and a `close` declared beside a `fighting_withdrawal` or a `retreat` counts among the others that split the round, so that round is refused and nothing advances. A round whose only declarer declares a defensive move is legal, because everyone agreed. A fighting withdrawal here is a pure move and pinned as one: RAW gives the withdrawing combatant the backward move and takes nothing away, so the attack stays with them, but a member declares one thing per round and the whole formation moves together, so a round the party withdraws in is a round it does not attack in. The SRD's attack-while-withdrawing half is not represented. The move set is the SRD's two defensive moves and `close`, with no fourth. Retiring the fourth value is schema version 4, with a lossless migration that clears it off a logged declaration and leaves a member who declared it holding, as that round played out. A party melee attack lands on the first living, visible monster of the group's reachable rank (deterministic, no draw); monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted — a class ability, not a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with the same checks it passed, because the phases before it can change what those checks read. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field holds the first rejection code the re-check produced. The reachable case is an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration`. ### Phase 3 effect consumption in battle diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index 1c5aea3..8c888e3 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -1022,20 +1022,29 @@ class SpellCastEvent(Event): class SpellDisruptedEvent(Event): - """A caster was interrupted and the spell came to nothing. - - [`disrupt_casting`][osrlib.core.spells.disrupt_casting] emits this when a caster who declared a spell is hit, - or otherwise stopped, before it goes off. The memorized copy is lost exactly as if it had been cast. + """A declared spell came to nothing, and the caster lost it anyway. + + Two things produce one, and `code` tells them apart. + [`disrupt_casting`][osrlib.core.spells.disrupt_casting] emits `magic.cast.disrupted` when a caster who + declared a spell is hit, or otherwise stopped, before it goes off. The battle round emits + `magic.cast.fizzled` when it judges the declaration again in the magic phase, just before the spell would + resolve, and a check the declaration passed at the top of the round no longer passes, because the phases + before it changed what that check reads. The reachable case is an ally's *silence 15' radius* anchoring on + the party's cell earlier in the same magic phase. `reason` carries the rejection code behind a fizzle. + + Either way nothing resolved and the memorized copy is gone, exactly as if the spell had been cast, so tell + the player the spell failed and the prepared copy is spent. A scroll read that fizzles spends the scroll + the same way, and the caster loses no memorized copy, because a read never used one. """ - allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.cast.disrupted"}) - """The only code this event uses.""" + allowed_codes: ClassVar[frozenset[str]] = frozenset({"magic.cast.disrupted", "magic.cast.fizzled"}) + """The two codes this event uses: `magic.cast.disrupted` and `magic.cast.fizzled`.""" event_type: Literal["spell_disrupted"] = "spell_disrupted" """The wire name for this event type.""" code: str = "magic.cast.disrupted" - """Fixed at `magic.cast.disrupted`.""" + """Which of the two failures this is, defaulting to `magic.cast.disrupted`.""" visibility: Visibility = Visibility.PLAYER """Player visibility: the caster's spell visibly fails.""" @@ -1049,6 +1058,16 @@ class SpellDisruptedEvent(Event): reversed: bool = False """True when the lost copy was the spell's reversed form.""" + reason: str | None = None + """Why a `magic.cast.fizzled` spell failed: the first rejection code the magic phase's re-check produced. + + An ally's silence on the party's cell reads `magic.cast.silenced_area`. Match on this rather than on any + text, the way you match on a [`Rejection`][osrlib.core.validation.Rejection]'s own code, and + [the rejection code reference][rejection-codes] lists what each one means. `None` on a + `magic.cast.disrupted` event, which needs no reason beyond the blow that landed, and on a log written + before the field existed. + """ + class SpellForgottenEvent(Event): """A memorized spell slipped away because the caster no longer has room for it. diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 1363909..4384078 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -162,7 +162,7 @@ ) from osrlib.core.dice import roll from osrlib.core.effects import EFFECTS_STREAM, Condition, has_condition -from osrlib.core.events import AttackRolledEvent, Event, SavingThrowRolledEvent +from osrlib.core.events import AttackRolledEvent, Event, SavingThrowRolledEvent, SpellDisruptedEvent from osrlib.core.items import ( GearTemplate, ItemInstance, @@ -1472,11 +1472,10 @@ def _resolve_scroll_cast(session, member, instance, template, declaration: Battl spell_id = declaration.spell_id or remaining[0] spell = load_spells().get(spell_id) mode = declaration.spell_mode or spell.modes[0].key - targets, distance, rejections = _cast_targets( - session, declaration.model_copy(update={"spell_id": spell_id, "spell_mode": mode}), spell - ) - if rejections: - return [] + # The declaration is judged again, with the checks it passed at the top of the round, + # because the phases before this one can change what those checks read (see the + # adaptations register). The scroll is spent afterwards either way: the reader read it. + rejections = _validate_magic_item_declaration(session, declaration, member, instance) left = tuple(spell_name for spell_name in remaining if spell_name != spell_id) + tuple( spell_id for _ in range(remaining.count(spell_id) - 1) ) @@ -1487,6 +1486,12 @@ def _resolve_scroll_cast(session, member, instance, template, declaration: Battl events: list[Event] = [] events.extend(exploration._identify_item_events(session, member, instance)) events.append(ItemUsedEvent(code="items.scroll.read", character_id=member.id, instance_id=instance.instance_id)) + if rejections: + events.append(_fizzle_event(member.id, spell_id, reversed=declaration.reversed, reason=rejections[0].code)) + return events + targets, distance, _ = _cast_targets( + session, declaration.model_copy(update={"spell_id": spell_id, "spell_mode": mode}), spell + ) definition = load_classes().get(member.class_id) from osrlib.core.spells import caster_profile @@ -2038,6 +2043,33 @@ def _break_invisibility(session, member) -> list[Event]: return events +def _fizzle_event(caster_id: str, spell_id: str, *, reversed: bool, reason: str) -> SpellDisruptedEvent: + """Report a declaration the magic phase's re-check refused, at `magic.cast.fizzled`. + + `reason` is the first rejection code the re-check produced, which is what tells a front end why the + spell failed. A refused declaration never raises out of the round, so this event is the whole outcome. + """ + return SpellDisruptedEvent( + code="magic.cast.fizzled", caster_id=caster_id, spell_id=spell_id, reversed=reversed, reason=reason + ) + + +def _fizzle_cast(session, member, declaration: BattleDeclaration, reason: str, state) -> list[Event]: + """Lose a declared cast the magic phase's re-check refused, the way a disruption loses it. + + The caster gives up the memorized copy through + [`disrupt_casting`][osrlib.core.spells.disrupt_casting], so the declared form is what goes, and + concentration releases as it does on any other action. A caster who no longer has a copy to give up, + because the round took it some other way, keeps the event and loses nothing twice. + """ + spell_id = declaration.spell_id or "" + if any(copy.spell_id == spell_id for copy in getattr(member, "memorized_spells", ())): + disrupt_casting(member, spell_id, reversed=declaration.reversed) + events: list[Event] = [_fizzle_event(member.id, spell_id, reversed=declaration.reversed, reason=reason)] + events.extend(_release_concentration(session, member.id, state)) + return events + + def _party_magic(session, by_member, pending_casters, disrupted, acted, state) -> list[Event]: events: list[Event] = [] for member, declaration in by_member.values(): @@ -2083,6 +2115,14 @@ def _party_magic(session, by_member, pending_casters, disrupted, acted, state) - events.extend(_release_concentration(session, member.id, state)) acted.add(member.id) continue + # The declaration is judged again, with the checks it passed at the top of the + # round, because the phases before this one can change what those checks read + # (see the adaptations register). Nothing is spent and nothing is drawn first. + recheck = _validate_declaration(session, declaration, member) + if recheck: + events.extend(_fizzle_cast(session, member, declaration, recheck[0].code, state)) + acted.add(member.id) + continue spell = load_spells().get(declaration.spell_id) targets, distance, _ = _cast_targets(session, declaration, spell) from osrlib.core.spells import CastContext, caster_profile diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index e835cdf..29f9e4c 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -214,6 +214,11 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: "magic.cast.disrupted": lambda event: ( f"{event.caster_id}'s casting of {event.spell_id} is disrupted — the spell is lost." ), + "magic.cast.fizzled": lambda event: ( + f"{event.caster_id}'s casting of {event.spell_id} fizzles" + + (f" ({event.reason})" if event.reason else "") + + " - the spell is lost." + ), "magic.memory.forgotten": lambda event: f"{event.caster_id} forgets {event.spell_id}.", "magic.book.added": lambda event: f"{event.caster_id} adds {event.spell_id} to their spell book.", "magic.turning.turned": lambda event: _turning(event, "the undead are turned"), diff --git a/tests/goldens/phase3_spell_battle.json b/tests/goldens/phase3_spell_battle.json index b4d1ea5..34f9d24 100644 --- a/tests/goldens/phase3_spell_battle.json +++ b/tests/goldens/phase3_spell_battle.json @@ -224,6 +224,7 @@ "caster_id": "pc-mu", "code": "magic.cast.disrupted", "event_type": "spell_disrupted", + "reason": null, "reversed": false, "spell_id": "fire_ball", "visibility": "player" diff --git a/tests/goldens/phase4_delve.json b/tests/goldens/phase4_delve.json index e778834..5805f9e 100644 --- a/tests/goldens/phase4_delve.json +++ b/tests/goldens/phase4_delve.json @@ -3749,6 +3749,7 @@ "caster_id": "character-0005", "code": "magic.cast.disrupted", "event_type": "spell_disrupted", + "reason": null, "reversed": false, "spell_id": "magic_missile", "visibility": "player" From 3a9c878bbe26a01f2c2fe511f5f567b144e8d407 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:51:56 -0700 Subject: [PATCH 2/3] Corrections from the review of the magic-phase re-check (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default English line for magic.cast.fizzled printed the reason code in parentheses, the only template in the module that shows a player a raw dotted code. It now reads "'s casting of fizzles — the spell is lost.", with the module's own punctuation; reason stays on the event, where a front end reads it. The prose was narrower than the code. The changelog bullet, the adaptations register, the SpellDisruptedEvent docstring, and the reason field's docstring each said the reachable case is an ally's silence. The rule is that the magic phase re-runs every check the declaration passed and any one of them failing fizzles it, so they now state that and give two examples: silence anchored on the party's cell, refused with magic.cast.silenced_area, and a scroll read left dark when the torch bearer dies in the missile phase, refused with exploration.action.requires_light. Four docstrings for rules the code enforced and nothing stated. _validate_declaration and _validate_magic_item_declaration say the magic phase runs them again immediately before resolution, and that they read state and take no draw, so the second run costs nothing. _party_magic says a disrupted caster is reported as disrupted before the re-check runs, so disruption wins. _resolve_scroll_cast says the read is emitted and the spell struck off before a fizzle is reported, because the reader did read it, and that a thief's error roll carries no fizzle event of its own. _fizzle_cast loses two unreachable defaults. It takes the spell id and the reversed flag directly instead of the declaration, so there is no `or ""` for a cast that cannot reach the magic phase without a spell id, and it reads member.memorized_spells rather than getattr with a default for a declared field. Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- CHANGELOG.md | 2 +- docs/adaptations.md | 2 +- src/osrlib/core/events.py | 15 ++++++----- src/osrlib/crawl/battle.py | 55 ++++++++++++++++++++++++++++++++------ src/osrlib/messages.py | 6 +---- 5 files changed, 59 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1533088..820e052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Discovering a secret door clears the `room_traps` search attempts of both cells of that edge, so every member may search either cell again (#45, #129). 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 searches while the door is still wall spends their attempt with no chance at that trap. 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. The refund reaches the far cell too, whichever side the discovery came from, because a search from either cell covers the same edge. - [`check_fatigue`][osrlib.crawl.exploration.check_fatigue] attaches the unrested-fatigue penalty to each living member who lacks it, so a member who joins an already-tired party, or who loses the effect some other way, is fatigued beside their companions rather than waiting for the next [`Rest`][osrlib.crawl.commands.Rest] to reset the counter (#100). `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 fully fatigued party reports the check as quiet. -- A battle declaration is judged again in the magic phase, immediately before it resolves, so a cast the rules can no longer allow fizzles instead of going off or raising out of the round (#129). The declaration passes its checks when the round is accepted, and the phases before the magic phase can change what those checks read: the reachable case is *silence 15' radius*, where a creature that passes its save leaves the area anchored on the party's cell, so an ally's silence resolving earlier in the same magic phase silences a caster whose declaration was accepted with no silence on the cell. [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] now re-runs the same checks a `cast` declaration and a scroll-read `use_item` declaration passed, against the state as it stands then and before anything is spent or drawn. A refused one is not cast: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with a [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] at the new code `magic.cast.fizzled`, whose new `reason` field holds the first rejection code the re-check produced, `magic.cast.silenced_area` for the silence. `reason` defaults `None`, so a log written before the field existed still parses, and a `magic.cast.disrupted` event leaves it unset. A scroll read that fizzles emits its `items.scroll.read` event first, because the reader did read it. Match on `reason` the way you match on a rejection's own code. A declaration the re-check accepts resolves as it always did, with the same draws in the same order. +- A battle declaration is judged again in the magic phase, immediately before it resolves, so a cast or a scroll read the rules can no longer allow fizzles instead of going off or raising out of the round (#129). [`ResolveBattleRound`][osrlib.crawl.commands.ResolveBattleRound] re-runs every check a `cast` declaration and a scroll-read `use_item` declaration passed when the round was accepted, against the state as it stands then and before anything is spent or drawn, and failing any one of them fizzles the declaration. That is the rule; which check fails is whatever the round did to the caster. Two that happen: an ally's *silence 15' radius* anchors on the party's cell when a creature passes its save, so a silence resolving earlier in the same magic phase silences a caster whose declaration was accepted with no silence on the cell, and the party's last torch goes out as its bearer dies in the missile phase, leaving a scroll reader without the light a read needs. A refused declaration is not cast: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with a [`SpellDisruptedEvent`][osrlib.core.events.SpellDisruptedEvent] at the new code `magic.cast.fizzled`, whose new `reason` field holds the first rejection code the re-check produced, `magic.cast.silenced_area` for the silence and `exploration.action.requires_light` for the lost torch. Read `reason` rather than assuming a case, and match on it the way you match on a rejection's own code. `reason` defaults `None`, so a log written before the field existed still parses, and a `magic.cast.disrupted` event leaves it unset: a caster the round already disrupted keeps reporting `magic.cast.disrupted`, because that check runs before the re-check. A scroll read that fizzles emits its `items.scroll.read` event first, because the reader did read it. A declaration the re-check accepts resolves as it always did, with the same draws in the same order. - A scroll read is judged at the scroll's own caster level, in the kernel and in the crawl layer alike (#117). [`cast_from_scroll`][osrlib.core.spells.cast_from_scroll] validates and resolves at the spell's minimum caster level, 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. The kernel 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 [`UseItem`][osrlib.crawl.commands.UseItem] and the `use_item` battle declaration, so there is one statement of the rule and a front end deciding whether to offer a read 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] raise `ValueError` for a `tier` that is neither `"basic"` nor `"expert"` before their first draw, the check [`generate_magic_item`][osrlib.core.treasure.generate_magic_item] already makes. A bad tier costs no draws and yields no hoard, so a caller who passes a typo sees the bug rather than a plausible pile of coins and gems. A valid call's draw sequence is unchanged. diff --git a/docs/adaptations.md b/docs/adaptations.md index f14bc66..8e5eb81 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -340,7 +340,7 @@ Pursuit compares the party's slowest running rate (full feet per round) against ### The battle round -Battle declarations arrive as one command per round with one declaration per living, able member, rejected whole on any invalid declaration (partial acceptance would tangle the replay contract). Formation width is the frontage the party's own space offers under `formation_width_limit`: the widest square of unbroken floor that includes the party's cell, at five feet of frontage each — RAW's "2–3 characters in a 10' passage" pinned to its conservative end and then applied to whatever space the party stands in, so a ten-foot passage holds two however long it runs and a room holds its shorter side. Ranks recompute from the living marching order at round start and melee reaches the front rank only, both directions, with no firing-into-melee penalty (none exists in the SRD). The party moves as a formation — individual members cannot leave it (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate"; the running pursuit begins when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A `fighting_withdrawal` or a `retreat` that not every declarer chose is therefore not a legal declaration for that member: the round is refused whole with `battle.declaration.formation_split`, one rejection per defensive move the round split on, naming the declarers who chose it and the declarers who did not. `close` is not a defensive move and needs no agreement: a lone `close` advances the formation, and a `close` declared beside a `fighting_withdrawal` or a `retreat` counts among the others that split the round, so that round is refused and nothing advances. A round whose only declarer declares a defensive move is legal, because everyone agreed. A fighting withdrawal here is a pure move and pinned as one: RAW gives the withdrawing combatant the backward move and takes nothing away, so the attack stays with them, but a member declares one thing per round and the whole formation moves together, so a round the party withdraws in is a round it does not attack in. The SRD's attack-while-withdrawing half is not represented. The move set is the SRD's two defensive moves and `close`, with no fourth. Retiring the fourth value is schema version 4, with a lossless migration that clears it off a logged declaration and leaves a member who declared it holding, as that round played out. A party melee attack lands on the first living, visible monster of the group's reachable rank (deterministic, no draw); monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted — a class ability, not a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with the same checks it passed, because the phases before it can change what those checks read. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field holds the first rejection code the re-check produced. The reachable case is an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration`. +Battle declarations arrive as one command per round with one declaration per living, able member, rejected whole on any invalid declaration (partial acceptance would tangle the replay contract). Formation width is the frontage the party's own space offers under `formation_width_limit`: the widest square of unbroken floor that includes the party's cell, at five feet of frontage each — RAW's "2–3 characters in a 10' passage" pinned to its conservative end and then applied to whatever space the party stands in, so a ten-foot passage holds two however long it runs and a room holds its shorter side. Ranks recompute from the living marching order at round start and melee reaches the front rank only, both directions, with no firing-into-melee penalty (none exists in the SRD). The party moves as a formation — individual members cannot leave it (the Bard's Tale convention): all-retreat moves off at the full encounter rate (Combat.md's "full encounter movement rate"; the running pursuit begins when the battle converts at round end), all-fighting-withdrawal backs off at half encounter rate, and the first `close` declaration advances the formation on its named group, stopping at 5'. A `fighting_withdrawal` or a `retreat` that not every declarer chose is therefore not a legal declaration for that member: the round is refused whole with `battle.declaration.formation_split`, one rejection per defensive move the round split on, naming the declarers who chose it and the declarers who did not. `close` is not a defensive move and needs no agreement: a lone `close` advances the formation, and a `close` declared beside a `fighting_withdrawal` or a `retreat` counts among the others that split the round, so that round is refused and nothing advances. A round whose only declarer declares a defensive move is legal, because everyone agreed. A fighting withdrawal here is a pure move and pinned as one: RAW gives the withdrawing combatant the backward move and takes nothing away, so the attack stays with them, but a member declares one thing per round and the whole formation moves together, so a round the party withdraws in is a round it does not attack in. The SRD's attack-while-withdrawing half is not represented. The move set is the SRD's two defensive moves and `close`, with no fourth. Retiring the fourth value is schema version 4, with a lossless migration that clears it off a logged declaration and leaves a member who declared it holding, as that round played out. A party melee attack lands on the first living, visible monster of the group's reachable rank (deterministic, no draw); monsters pick uniformly on the monster-action stream. Under individual initiative the machine still resolves side blocks, ordered by the best individual total (the SRD's phase sequence is per side). Turn undead resolves in the magic phase, needs no declaration posting, and cannot be disrupted — a class ability, not a spell. The machine detects disruption per the RAW trigger (a declared caster successfully attacked or failing a save before acting) and releases concentration on any other declared action. A declaration is judged twice: once when the round is accepted, and again in the magic phase immediately before it resolves, with every check it passed the first time, because the phases between the two can change what those checks read. Failing any one of them fizzles the declaration, which is the rule; the cases are whatever the round did. Two that happen are an ally's *silence 15' radius* anchoring on the party's cell earlier in the same magic phase, which refuses a cast with `magic.cast.silenced_area`, and the party's last torch going out as its bearer dies in the missile phase, which refuses a scroll read with `exploration.action.requires_light`. A cast or scroll read the re-check refuses is not cast and never raises out of the round: the caster loses the memorized copy, or the scroll its spell, exactly as a disruption loses it, and the round reports it with `magic.cast.fizzled` on `SpellDisruptedEvent`, whose `reason` field holds the first rejection code the re-check produced. A caster the round already disrupted reports `magic.cast.disrupted` instead, because that check runs first. Each battle round advances the clock one round through the ledger. Locked by `test_battle.py`, the split refusal by `test_battle.py::TestAFormationMoveNeedsEveryDeclarer`, and the re-check by `test_battle.py::TestTheMagicPhaseRechecksADeclaration`. ### Phase 3 effect consumption in battle diff --git a/src/osrlib/core/events.py b/src/osrlib/core/events.py index 8c888e3..36d661f 100644 --- a/src/osrlib/core/events.py +++ b/src/osrlib/core/events.py @@ -1028,9 +1028,10 @@ class SpellDisruptedEvent(Event): [`disrupt_casting`][osrlib.core.spells.disrupt_casting] emits `magic.cast.disrupted` when a caster who declared a spell is hit, or otherwise stopped, before it goes off. The battle round emits `magic.cast.fizzled` when it judges the declaration again in the magic phase, just before the spell would - resolve, and a check the declaration passed at the top of the round no longer passes, because the phases - before it changed what that check reads. The reachable case is an ally's *silence 15' radius* anchoring on - the party's cell earlier in the same magic phase. `reason` carries the rejection code behind a fizzle. + resolve, and any one of the checks it passed at the top of the round no longer passes, because the phases + between the two changed what that check reads. An ally's *silence 15' radius* anchoring on the party's + cell and the party's last torch going out as its bearer dies are two that happen; the rule is the whole + set of checks, not a list of cases. `reason` carries the rejection code behind a fizzle. Either way nothing resolved and the memorized copy is gone, exactly as if the spell had been cast, so tell the player the spell failed and the prepared copy is spent. A scroll read that fizzles spends the scroll @@ -1061,9 +1062,11 @@ class SpellDisruptedEvent(Event): reason: str | None = None """Why a `magic.cast.fizzled` spell failed: the first rejection code the magic phase's re-check produced. - An ally's silence on the party's cell reads `magic.cast.silenced_area`. Match on this rather than on any - text, the way you match on a [`Rejection`][osrlib.core.validation.Rejection]'s own code, and - [the rejection code reference][rejection-codes] lists what each one means. `None` on a + Any check a declaration passes at the top of the round can produce it, so read the code rather than + assuming a case: an ally's silence on the party's cell reads `magic.cast.silenced_area`, and a scroll + read left in the dark reads `exploration.action.requires_light`. Match on this the way you match on a + [`Rejection`][osrlib.core.validation.Rejection]'s own code, and + [the rejection code reference][rejection-codes] says what each one means. `None` on a `magic.cast.disrupted` event, which needs no reason beyond the blow that landed, and on a log written before the field existed. """ diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index 923c569..2ce3801 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -1163,6 +1163,13 @@ def _formation_split_rejections(declarers, declarations: Sequence[BattleDeclarat def _validate_declaration(session, declaration: BattleDeclaration, member) -> list[Rejection]: + """Judge one declaration against the state it names, and return every reason it cannot stand. + + This runs twice on the declarations the round accepts. Once in the validation pre-phase, where a + rejection refuses the whole command, and again in the magic phase, immediately before a `cast` + resolves, because the phases between the two can change what these checks read. It reads state and + takes no draw, so running it a second time costs nothing and changes nothing. + """ state = session.battle if declaration.action == "hold": return [] @@ -1331,6 +1338,11 @@ def _validate_magic_item_declaration(session, declaration: BattleDeclaration, me effects and resolving them there keeps the missile and melee ordering clean. A scroll read resolves in the magic phase too, through the declaration's spell fields. + + For a scroll this runs twice, as `_validate_declaration`'s `cast` branch does: once in the + validation pre-phase, and again in the magic phase immediately before the read resolves, because the + phases between the two can change what these checks read. It reads state and takes no draw, so the + second run costs nothing and changes nothing. """ from osrlib.crawl import exploration @@ -1461,6 +1473,14 @@ def __init__(self, *, spell_id, mode, targets, target_id) -> None: def _resolve_scroll_cast(session, member, instance, template, declaration: BattleDeclaration) -> list[Event]: + """Read one spell off a scroll in the magic phase, at the scroll's own caster level. + + The declaration is judged again first, before anything is spent, and a refused one still spends the + scroll: the read itself is emitted and the spell struck off, and only then is the fizzle reported, + because the reader did read it and the ink is gone either way. A thief reading an arcane scroll rolls + the printed error chance after that, and a failed roll burns the spell with nothing to report: that + miscast is the scroll's own, not a refused declaration, so it carries no `magic.cast.fizzled` event. + """ from osrlib.core.spells import CastContext, cast_from_scroll from osrlib.crawl import exploration from osrlib.crawl.events import ItemUsedEvent @@ -2053,23 +2073,33 @@ def _fizzle_event(caster_id: str, spell_id: str, *, reversed: bool, reason: str) ) -def _fizzle_cast(session, member, declaration: BattleDeclaration, reason: str, state) -> list[Event]: +def _fizzle_cast(session, member, spell_id: str, *, reversed: bool, reason: str, state) -> list[Event]: """Lose a declared cast the magic phase's re-check refused, the way a disruption loses it. The caster gives up the memorized copy through [`disrupt_casting`][osrlib.core.spells.disrupt_casting], so the declared form is what goes, and - concentration releases as it does on any other action. A caster who no longer has a copy to give up, - because the round took it some other way, keeps the event and loses nothing twice. + concentration releases as it does on any other action. A caster with no copy left to give up keeps the + event and loses nothing twice, which is what stops an earlier phase that already took the copy, such as + an energy drain, from raising here. """ - spell_id = declaration.spell_id or "" - if any(copy.spell_id == spell_id for copy in getattr(member, "memorized_spells", ())): - disrupt_casting(member, spell_id, reversed=declaration.reversed) - events: list[Event] = [_fizzle_event(member.id, spell_id, reversed=declaration.reversed, reason=reason)] + if any(copy.spell_id == spell_id for copy in member.memorized_spells): + disrupt_casting(member, spell_id, reversed=reversed) + events: list[Event] = [_fizzle_event(member.id, spell_id, reversed=reversed, reason=reason)] events.extend(_release_concentration(session, member.id, state)) return events def _party_magic(session, by_member, pending_casters, disrupted, acted, state) -> list[Event]: + """Resolve the party's magic phase: item uses, turning, and casts, in the order the declarations arrived. + + A caster the round already disrupted is reported as disrupted and takes no further part, and that + check runs before the re-check below, so a caster who was both hit and silenced reports + `magic.cast.disrupted` rather than `magic.cast.fizzled`. Disruption is the blow that landed, and it + is the outcome the table saw. + + Every other declaration is judged again immediately before it resolves, with the checks it passed at + the top of the round, and one that now fails any of them fizzles instead of reaching the kernel. + """ events: list[Event] = [] for member, declaration in by_member.values(): if declaration.action not in ("cast", "turn_undead", "use_item"): @@ -2119,7 +2149,16 @@ def _party_magic(session, by_member, pending_casters, disrupted, acted, state) - # (see the adaptations register). Nothing is spent and nothing is drawn first. recheck = _validate_declaration(session, declaration, member) if recheck: - events.extend(_fizzle_cast(session, member, declaration, recheck[0].code, state)) + events.extend( + _fizzle_cast( + session, + member, + declaration.spell_id, + reversed=declaration.reversed, + reason=recheck[0].code, + state=state, + ) + ) acted.add(member.id) continue spell = load_spells().get(declaration.spell_id) diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 29f9e4c..93cb549 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -214,11 +214,7 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str: "magic.cast.disrupted": lambda event: ( f"{event.caster_id}'s casting of {event.spell_id} is disrupted — the spell is lost." ), - "magic.cast.fizzled": lambda event: ( - f"{event.caster_id}'s casting of {event.spell_id} fizzles" - + (f" ({event.reason})" if event.reason else "") - + " - the spell is lost." - ), + "magic.cast.fizzled": lambda event: f"{event.caster_id}'s casting of {event.spell_id} fizzles — the spell is lost.", "magic.memory.forgotten": lambda event: f"{event.caster_id} forgets {event.spell_id}.", "magic.book.added": lambda event: f"{event.caster_id} adds {event.spell_id} to their spell book.", "magic.turning.turned": lambda event: _turning(event, "the undead are turned"), From 7ae61d55fe3a734227ed938914146acdac58ad73 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 14 Sep 2026 21:53:42 -0700 Subject: [PATCH 3/3] Unmark the magic-phase re-check acceptance tests Claude-Session: https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs --- tests/test_battle.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_battle.py b/tests/test_battle.py index 70ff310..392c00b 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -1301,7 +1301,6 @@ def _round_with_silence_then_missile(seed: int): result = session.execute(ResolveBattleRound(declarations=hold_all(session, extra=(silence, missile)))) return session, result - @pytest.mark.xfail(reason="chunk: magic-phase-recheck") def test_a_cast_silenced_by_an_earlier_ally_fizzles_instead_of_resolving(self): from osrlib.core.events import SpellDisruptedEvent from osrlib.crawl import exploration @@ -1332,7 +1331,6 @@ def test_a_cast_silenced_by_an_earlier_ally_fizzles_instead_of_resolving(self): assert fizzled_seeds, "no seed anchored the silence on the party's cell" assert resolved_seeds, "no seed let the missile resolve" - @pytest.mark.xfail(reason="chunk: magic-phase-recheck") def test_the_fizzle_code_is_declared_and_has_a_template(self): from osrlib.core.events import SpellDisruptedEvent from osrlib.messages import format_message