Add Birgi, God of Storytelling // Harnfel, Horn of Bounty - #6174
Conversation
The card was already ~98% supported; the code change is a swallow-detector
fix: the activation-limit carve-out in detect_dynamic_qty only recognized
"twice each turn", so Birgi's "can boast twice during each of your turns
rather than once" raised a false-positive SwallowedClause{DynamicQty}.
Extract a shared twice_is_activation_limit predicate keyed on the
"rather than once" limit-raise idiom (class-general per CR 702.142b
referability + CR 602.5b activation restrictions), unifying the two
previously-duplicated call sites. Also removes a pre-existing citation of
nonexistent CR 605.1g.
Integration coverage (8 tests): mana trigger uses the stack (CR 605.1b)
and stamps ManaExpiry::EndOfTurn (CR 614.17 overriding CR 106.4) with
persistence + drain; boast-twice limit with/without Birgi (CR 702.142a);
controller scoping negatives; Harnfel back-face cast (ChooseModalFace)
granting PlayFromExile until end of turn, plus a DB-independent synthetic
sibling. Fixture gains exactly the two new card entries (0 removed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request extracts the logic for identifying whether "twice" denotes a fixed-count activation limit into a shared helper function twice_is_activation_limit in swallow_check.rs. This helper prevents false-positive DynamicQty swallow warnings for cards like Birgi, God of Storytelling, whose boast-limit static is a fixed activation limit rather than a dynamic quantity. The PR also adds comprehensive unit tests and a new integration test suite (birgi.rs) to verify Birgi's mana trigger, boast-twice limit, and Harnfel's back-face modal cast and play-from-exile behaviors. I have no feedback to provide as there are no review comments to assess.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Parse changes introduced by this PR✓ No card-parse changes detected. |
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the detector seam is promising, but the current test evidence is not merge-ready.
🔴 Blockers
crates/engine/tests/integration/birgi.rs:238,269attributes the raised boast limit to CR 702.142b, but that rule only defines what it means for an effect to refer to a boast ability. Likewise:310,366,481,574attributes the duration of the play permission to CR 601.2a, which describes beginning to cast a spell. Please replace or remove these citations using verified rules that actually support each assertion (for example, CR 702.142a plus the applicable activation restriction, and CR 611.2a for a resolution-created until-end-of-turn duration). The new614.17duration commentary should be similarly narrowed or paired with the duration rule.- The Harnfel paths only inspect a
CastingPermissionfield (:353-386and:561-594). They never exercise its consumer. Add a runtime assertion that an exiled spell is castable/plays this turn and that an unplayed sibling is no longer eligible after the turn changes; producer-state assertions alone cannot catch a broken permission-consumption or expiry path.
🟡 Evidence gap
- The current parse-diff says “No card-parse changes,” while the local generated Birgi card data still contains the
DynamicQtyswallowed-clause warning that this PR says it removes. Please reconcile that artifact before re-review so the card-level parser effect is independently visible.
The shared twice_is_activation_limit predicate and its reach-guarded detector test are otherwise directionally sound. Request re-review after the citations, consumer/expiry regression, and parse-diff discrepancy are addressed.
…nsumer coverage BLOCKER 1 (wrong CR citations, birgi.rs): - Boast-limit assertions cited CR 702.142b (which only defines that an effect "refers to a boast ability"). Replaced with CR 702.142a (boast's base "Activate ... only once each turn" restriction) + CR 602.5 (activation restrictions). Applied the same correction to the swallow_check.rs comments and the boast-static unit test. - Play-from-exile permission DURATION cited CR 601.2a (beginning to cast a spell). Replaced all four with CR 611.2a (a continuous effect from resolution lasts "until end of turn"), matching the engine's own annotation at turns.rs (prune_end_of_turn_casting_permissions: CR 514.2 + CR 611.2a). - Narrowed the CR 614.17 "don't lose this mana" can't-effect commentary by pairing it with CR 611.2a (the actual until-end-of-turn duration rule) so the persistence-vs-drain split cites the right rule on each side. - Verified every citation (including 702.142a, 602.5, 611.2a, 614.17, 514.2, 712.11b) against docs/MagicCompRules.txt. BLOCKER 2 (consumer/expiry runtime coverage, birgi.rs): - The two Harnfel tests only inspected the produced CastingPermission. Added a shared assert_play_from_exile_consumed_then_expires drill that (a) casts one exiled card from exile this turn through the real pipeline and asserts it resolves via CastOutcome deltas (assert_zone → Graveyard, assert_life_delta +1) and leaves P0's cast path, and (b) advances a turn and asserts the still unplayed sibling is no longer castable and its PlayFromExile grant was pruned (CR 611.2a / CR 514.2). Exiled cards are now real 0-cost sorceries (castable_top helper) so the grant has something to consume. BLOCKER 3 (parse-diff discrepancy): no code change — documented limitation. coverage-parse-diff diffs ONLY the parse_details tree; the swallowed-clause warning lives in card-level gap_details/supported (Swallow:DynamicQty), never in parse_details. Birgi's ModifyActivationLimit(boast,2) static was already supported:true in parse_details before the fix, so the fix leaves parse_details byte-identical and the "No card-parse changes detected" result is correct by design. The removal is visible only in coverage gap_details/supported, which the parse-diff comment does not surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the detailed review. All three blockers are addressed in Blocker 1 — CR citations (birgi.rs)Ran the Boast limit (was
The base limit is
Retagged the boast doc-comment + assertion to Play-from-exile duration (was
This also matches the engine's own annotation at
Blocker 2 — consumer/expiry runtime coverage (birgi.rs)Both Harnfel tests previously only inspected the produced
Followed the existing Local run: Blocker 3 — parse-diff "No card-parse changes detected" (reconciliation)This is correct by design, not a stale artifact — the swallow-detector carve-out does not change any card's parse tree. Traced the pipeline: Before/after evidence (Birgi entry from the locally-generated Before (pre-fix): "supported": false, "gap_count": 1,
"gap_details": [{"handler": "Swallow:DynamicQty",
"source_text": "[warning:swallowed-clause] DynamicQty — Creatures you control can boast twice during each of your turns rather than once."}],
"parse_details": [
{"category":"trigger","label":"SpellCast","supported":true, "children":[{"label":"Mana","supported":true,"details":[["mana","{R}"],["expiry","EndOfTurn"]]}]},
{"category":"static","label":"ModifyActivationLimit(boast,2)","supported":true,"details":[["affects","you control creature"]]}
]The
Evidence package
(This PR's swallow-detector change is comment-only in this commit; the carve-out itself keeps its Verification: |
|
@matthewevans — all three blockers are addressed in a40a7af (details in the comment above). Re-requesting review; I don't have permission to formally re-request reviewers on a fork PR, so flagging here. Thanks! |
matthewevans
left a comment
There was a problem hiding this comment.
Changes requested — the prior CR and runtime-permission blockers are resolved, but the new DynamicQty exemption is still broader than the typed parser seam.
🔴 Blocker
-
crates/engine/src/parser/swallow_check.rs:2060-2067treats any unit containing"rather than once"as an activation-limit form. That is not tied to the only parser production that earns this exemption,StaticMode::ModifyActivationLimitincrates/engine/src/parser/oracle_static/dispatch.rs:2735-2757. An unsupported future clause such as a copy/repeat instruction using “twice … rather than once” (withouttwice thatortwice x) would now skip the DynamicQty detector even though no typed activation-limit carrier was produced. The same predicate also feeds therepeat_forsuppression atswallow_check.rs:2271.Please make the exemption evidence-backed: retain the existing narrow textual shape only as a locator, but require the scoped
UnitEvidenceto containStaticMode::ModifyActivationLimit { .. }before suppressing the marker. Thread that evidence through the repeat-for helper as needed, and add a negative regression proving a non-ModifyActivationLimit“twice … rather than once” unit still reportsDynamicQty.
✅ Clean
- The current head’s Harnfel tests now drive the real cast consumer and assert expiry across a turn boundary (
birgi.rs:158-205, 466-468, 671-673), addressing the previous producer-only test gap. - The revised citations are supported by the checked local CR source: 702.142a, 602.5b, 611.2a, 514.2, 614.17, 106.4, 605.1b, and 712.11b.
- The current parse-diff artifact reports no unrelated card-parse changes.
Recommendation: narrow the detector exemption to the typed ModifyActivationLimit evidence, add the negative coverage-honesty regression, and request re-review on that head.
# Conflicts: # crates/engine/tests/fixtures/integration_cards.json
…tion The 'rather than once' arm of twice_is_activation_limit now requires the scoped UnitEvidence to carry StaticMode::ModifyActivationLimit before suppressing the DynamicQty marker; the textual shape is a locator only. Threaded through both consumers (has_marker gate and the repeat_for suppression path). Adds a negative regression proving an unbacked 'twice ... rather than once' unit still reports DynamicQty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Re-review requested on head 1b809c4 — both asks from the last round are addressed:
Also merged Verified locally: clippy clean, 186 swallow-check lib tests and all 8 Birgi/Harnfel integration tests pass; parser combinator gates (G + A) pass pre-commit. 🤖 Generated with Claude Code |
|
Maintainer follow-up: the adversarial pass found that an unrelated |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # crates/engine/tests/fixtures/integration_cards.json
|
Thanks for the adversarial pass and for The red CI on that head is a fixture escaping slip, not a logic problem. In the new test the fixture string at Your adjacent test One-line proof: restore the I also merged |
matthewevans
left a comment
There was a problem hiding this comment.
Approved after the repaired fixture, evidence-backed DynamicQty exemption, current no-change parse diff, and focused re-review. Merge-when-ready will wait for the current CI run.
Adds Birgi, God of Storytelling // Harnfel, Horn of Bounty (KHM #123, modal DFC).
Model: claude-fable-5
Tier: Frontier
Thinking: enabled
What this PR actually changes
The card was already ~98% supported by existing building blocks (SpellCast trigger →
Mana { expiry: EndOfTurn },ModifyActivationLimitfor boast-twice,ExileTop+GrantCastingPermission::PlayFromExilefor Harnfel,ChooseModalFacefor MDFC casting). The only code change is a swallow-detector fix: the activation-limit carve-out indetect_dynamic_qtyrecognized only "twice each turn", so Birgi's templating "can boast twice during each of your turns rather than once" raised a false-positiveSwallowedClause{DynamicQty}even though the line parses fully toModifyActivationLimit { keyword: "boast", new_limit: 2 }.Fix (class-general, not card-specific): extract a shared
twice_is_activation_limit(cleaned)predicate keyed on the "rather than once" limit-raise idiom — grep-confirmed unique in the corpus to theModifyActivationLimitparser family (CR 702.142b makes boast a referable ability; CR 602.5b governs the usage-limit raise) — and route both previously-duplicated call sites (detect_dynamic_qty,cleaned_twice_is_only_dynamic_marker) through it so they cannot drift. The existing "twice that"/"twice x" dynamic-multiplier exclusions are preserved and tested. Also removes a pre-existing citation of nonexistent CR 605.1g (grep of the CR text returns nothing), replaced with the two verified rules above.Zero new enum variants; zero serde/protocol shape changes.
Test coverage (8 integration + 2 unit)
birgi_mana_trigger_uses_stack_and_stamps_end_of_turn_expiry— strict shape: red pool empty at commit and trigger on the stack (CR 605.1b — a SpellCast trigger is not a mana ability), resolved mana is exactly {R} withManaExpiry::EndOfTurnasserted specifically (a misparse dropping the expiry fails), persists across steps/phases (CR 614.17 overriding CR 106.4), drains at turn end.birgi_two_casts_add_two_red— accumulation.boast_twice_with_birgi_rejects_third/boast_once_without_birgi_rejects_second— the limit raise (CR 702.142a base) with a funded-pool guard proving rejection is the limit, not mana; the without-Birgi leg proves the raise comes from Birgi's static.opponents_boast_creature_not_raised_by_your_birgi/opponents_cast_does_not_trigger_birgi_mana— controller scoping negatives, each with a positive reach-guard.harnfel_back_face_cast_grants_play_from_exile_this_turn— real-card MDFC test:CastSpell→ChooseModalFace { back_face: true }, discard-activation exiles top 2, both carryPlayFromExileuntil end of turn, a control card exiled by another source carries no grant. DB-guarded per house pattern.harnfel_exiles_top_two_and_grants_play_this_turn— DB-independent synthetic sibling.birgi_boast_limit_static_does_not_flag_dynamic_qty(with a reach-guard asserting the line parses toModifyActivationLimit— not vacuous) andtwice_is_activation_limit_scopes_to_fixed_limits_only("twice that many"/"twice x" still flag).Fixture note:
integration_cards.jsongains exactly the two new card entries (structural name-set diff: +2, −0, no shared card touched); a full deterministic regen was deliberately avoided because the committed fixture is stale relative to current generator output and a regen would sweep in ~130 unrelated card diffs. The Birgi entry matches post-fix generator output (noparse_warnings— the field is serialize-if-nonempty and this PR removes the false positive that produced it).Anchored on
Citations at PR base (
88f0a7ecb):parser/swallow_check.rs(the changed detector):swallow_check.rs:2064-2066— the pre-existing inlinetwice_is_activation_limitcarve-out (with the "twice that"/"twice x" exclusions) that this PR extracts into the shared predicate.swallow_check.rs:486— the analogous "activate only twice each turn" marker indetect_activate_limit(a different detector; intentionally untouched, same annotatedallow-noncombinatormarker-scan pattern).swallow_check.rs:2037—OTHER_DYNAMIC_MARKERS, the established marker-list pattern the predicate composes with.Parser family the predicate is keyed to:
parser/oracle_static/dispatch.rs:2735-2748— theModifyActivationLimitcombinator (take_until("rather than once")+tag("rather than once"), CR 702.142b annotation) — the sole in-tree consumer of the idiom.game/restrictions.rs:778-852—effective_activation_limit/activation_limit_from_statics, the runtime consumer of the parsed limit.Integration tests (
tests/integration/birgi.rs):tests/integration/peter_parker_modal_back_face_cast.rs:23-30,81-82— the MDFC back-face-cast pattern (load_dbguard +ChooseModalFace { back_face: true }) the Harnfel test follows.tests/integration/escape_to_wilds_2879.rs:44-52— theCastingPermission::PlayFromExilegrant-assertion precedent.game/mana_abilities.rs:99—is_triggered_mana_ability, the classifier the stack-not-inline assertion exercises (CR 605.1b).Gate A
Review record (final
review-impl)Independent reviewer (separate agent, adversarial lenses; re-ran all tests itself rather than trusting the executor's paste): APPROVE-WITH-CHANGES, both changes applied before this PR:
SwallowedClause{DynamicQty}parse_warningsthis PR eliminates — committed test data contradicting the change. Fixed by surgically removing the field (matching serialize-if-nonempty generator output); structural diff re-verified +2/−0.is_mana_ability: trueon Birgi's cast trigger is pre-existing generator output; the runtime classifier (mana_abilities::is_triggered_mana_ability) correctly overrides it andbirgi_mana_trigger_uses_stack_and_stamps_end_of_turn_expiryempirically pins stack behavior. Hand-editing the field would diverge the fixture from generator output; flagged here for maintainer awareness as a parser-output currency issue outside this PR's scope.Reviewer independently verified: fixture additions-only via structural JSON diff; "rather than once" appears on exactly 1 card in the full corpus and is the WotC limit-raise idiom (class-general, not verbatim-card matching); all negative assertions carry positive reach-guards; every CR cited in the diff grep-verified against the CR text (106.4, 601.2a, 602.5b, 605.1b, 614.17, 702.142a, 702.142b).
Verification: fmt clean;
swallow_checklib tests 149 passed;integration birgi8 passed;clippy -p engine --all-targetsexit 0 with zero warnings; parser combinator gate PASS (G + A);cargo coverageexit 0;cargo semantic-auditexit 0 (baseline unchanged).🤖 Generated with Claude Code