Judge a battle declaration again in the magic phase (#129) - #139
Merged
Merged
Conversation
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
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 "<caster>'s casting of <spell> 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A battle declaration passed its checks when the round was accepted, and the magic phase resolved it without asking again. Anything the earlier phases changed reached the kernel unjudged, so
cast_spellraisedValueErrorout 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 (the adaptations register), 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, and the caster cast anyway.
What changed
_party_magicre-runs_validate_declarationfor acastdeclaration, and_resolve_scroll_castre-runs_validate_magic_item_declarationfor a scroll-readuse_itemdeclaration, immediately before the declaration resolves and before anything is spent or drawn.disrupt_casting, or the scroll its spell exactly as a read would, concentration releases, the caster counts as having acted, and the round reports it with oneSpellDisruptedEventat the new codemagic.cast.fizzled.SpellDisruptedEventgainsreason: str | None = None, holding the first rejection code the re-check produced (magic.cast.silenced_areafor the silence).codestill defaults tomagic.cast.disrupted, visibility stays player, andreasondefaultsNoneso a log written before the field existed still parses.items.scroll.readevent first, because the reader did read it.magic.cast.disrupted.CHANGELOG.mdhas a bullet under Unreleased / Fixed.Why the accepted path is unchanged
Both re-checks are pure validators that run to completion before the first draw, so a declaration the re-check accepts resolves with the same draws in the same order.
Two golden files move by one line each and by nothing else.
reasondefaultsNone, so the key now serializes into every dumpedSpellDisruptedEvent, which is the same additive-field effect theheld,via, andoriginfields already have in these goldens. The whole delta:The phase 4 delve golden's
engine_versionstamp is deliberately left at the version that produced it, asAGENTS.mdrequires.Verification
tests/test_battle.py::TestTheMagicPhaseRechecksADeclarationis unedited and itsxfailmarkers stay in place for the lead to remove on merge.The full gate:
The two
xpassedare this chunk's tests, still carrying their markers.Closes #129
https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs