Skip to content

Judge a battle declaration again in the magic phase (#129) - #139

Merged
mmacy merged 5 commits into
mainfrom
chunk/magic-phase-recheck
Sep 15, 2026
Merged

mmacy merged 5 commits into
mainfrom
chunk/magic-phase-recheck

Conversation

@mmacy

@mmacy mmacy commented Sep 15, 2026

Copy link
Copy Markdown
Owner

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_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 (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_magic re-runs _validate_declaration for a cast declaration, and _resolve_scroll_cast re-runs _validate_magic_item_declaration for a scroll-read use_item declaration, immediately before the declaration resolves and before anything is spent or drawn.
  • A refused declaration is not cast and never raises out of the round. The caster loses the memorized copy through 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 one SpellDisruptedEvent at the new code magic.cast.fizzled.
  • SpellDisruptedEvent gains reason: str | None = None, holding the first rejection code the re-check produced (magic.cast.silenced_area for the silence). code still defaults to magic.cast.disrupted, visibility stays player, and reason defaults None so a log written before the field existed still parses.
  • A scroll read that fizzles emits its items.scroll.read event first, because the reader did read it.
  • The default formatter renders the new code as a line naming the caster, the spell, and the reason.
  • The disruption path is untouched: a caster hit before acting still reports magic.cast.disrupted.
  • The adaptations register pins the re-check in the battle paragraph and names the locking test class, and CHANGELOG.md has 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. reason defaults None, so the key now serializes into every dumped SpellDisruptedEvent, which is the same additive-field effect the held, via, and origin fields already have in these goldens. The whole delta:

$ git diff -U0 tests/goldens/ | grep -E "^[+-]" | grep -v "^[+-][+-][+-]"
+      "reason": null,
+      "reason": null,

The phase 4 delve golden's engine_version stamp is deliberately left at the version that produced it, as AGENTS.md requires.

Verification

tests/test_battle.py::TestTheMagicPhaseRechecksADeclaration is unedited and its xfail markers stay in place for the lead to remove on merge.

uv run pytest --runxfail "tests/test_battle.py::TestTheMagicPhaseRechecksADeclaration"
2 passed

The full gate:

uv run ruff format --check   160 files already formatted
uv run ruff check            All checks passed!
uv run pyright               0 errors, 0 warnings, 0 informations
uv run pytest                2929 passed, 146 skipped, 8 xfailed, 2 xpassed
uv run mkdocs build --strict Documentation built in 8.92 seconds

The two xpassed are this chunk's tests, still carrying their markers.

Closes #129

https://claude.ai/code/session_01NmCezTw8hKKujkaEZ3YGAs

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
@mmacy
mmacy merged commit 9c13bd2 into main Sep 15, 2026
4 checks passed
@mmacy
mmacy deleted the chunk/magic-phase-recheck branch September 15, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Two edges left by the search refund and the scroll pre-check

1 participant