Skip to content

feat(engine): gate continuous statics on the top card of the library#5692

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
minion1227:minion_top_of_library_condition
Jul 13, 2026
Merged

feat(engine): gate continuous statics on the top card of the library#5692
matthewevans merged 3 commits into
phase-rs:mainfrom
minion1227:minion_top_of_library_condition

Conversation

@minion1227

Copy link
Copy Markdown
Contributor

Summary

Adds engine support for the top-of-library static condition — a new StaticCondition::TopOfLibraryMatches { filter } that gates a continuous static on the top card of the source controller's library (library[0], CR 401.1) matching a characteristic TargetFilter. Covers the whole "As long as the top card of your library is X" class: Vampire Nocturnus (black), Mul Daya Channelers (creature/land card), Conspicuous Snoop (Goblin card), Crown of Convergence and Chittering Illuminator (creature card), Skill Borrower (artifact or creature card), and Oura, the Imitator (Faerie or instant card).

Files changed

  • crates/engine/src/types/ability.rs
  • crates/engine/src/parser/oracle_nom/condition.rs
  • crates/engine/src/game/layers.rs
  • crates/engine/src/game/ability_rw.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/game/quantity.rs
  • crates/engine/src/parser/oracle_effect/conditions.rs
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/parser/oracle_static/tests.rs

CR references

  • CR 401.1 — a library is a single ordered pile; its top card is library[0].
  • CR 401.5 — "play with the top card of your library revealed" statics gate on the top card, re-read live when the top changes.

Note on variant choice (not a sibling to parameterize)

TopOfLibraryMatches sits alongside the existing filter-scoped conditions IsPresent, SourceMatchesFilter, RecipientMatchesFilter, and DefendingPlayerControls — the engine already models each object-scope as its own variant rather than one parameterized MatchesFilter { scope }. This one is a distinct resolution context, not a leaf parameterization of SourceMatchesFilter: it resolves a library-position card (CR 401 zone access, library[0]) rather than a battlefield object (source_id / recipient_id), so it cannot ride an object-scope axis. It reuses the runtime match (matches_target_filter) and the parser vocabulary (parse_bare_predicate_disjunction) rather than re-implementing them.

Implementation method (required)

Method: not-applicable — authored directly against the oracle-parser skill and the add-engine-variant discipline (existence + parameterization + categorical-boundary checks). Pure AST + runtime change; no card-data regeneration required.

Track

Developer

LLM

Model: claude-opus-4-8
Thinking: high

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.
  • Verification below is for the current committed head (031d5f641).
  • Final review below is clean for the current committed head.
  • Both anchors cite existing analogous code at the same seam.

Results on head 031d5f641:

  • cargo test -p engine top_of_libraryok. 39 passed; 0 failed (4 parser + 1 runtime + 2 end-to-end new tests; 32 pre-existing top-of-library tests unaffected).
  • cargo clippy -p engine --all-targets --features proptest -- -D warningsFinished, 0 warnings.
  • cargo fmt --all — clean.

Anchored on

  • crates/engine/src/parser/oracle_nom/condition.rs (parse_recipient_is_filter_condition) — the analogous "it is a <filter>" condition that composes the same parse_bare_predicate_disjunction seam (bare colors, informational " card" suffix, "X or Y" disjunction).
  • crates/engine/src/game/layers.rs (SourceMatchesFilter arm in evaluate_condition_with_context) — the analogous "object matches a TargetFilter" runtime using matches_target_filter + FilterContext::from_source.

Final review

Self-review against the review-impl lenses on head 031d5f641. Verified: .front() on the library im::Vector is library[0] (the top, per the engine convention in game/zones.rs); a zoneless TargetFilter::Typed matches a library card on printed characteristics (no implicit battlefield requirement in filter_inner_for_object); the ability_rw profile reads StateKind::HandLibrary, which draw/scry/mill/shuffle write, so the gate re-evaluates when the top changes; the two population classifiers correctly place the variant in the false group (a battlefield entry never changes the library); and the ability/trigger bridges return None (a top-of-library gate has no effect-resolution or intervening-if equivalent). The passing runtime test discriminates on filter, player scope, and empty library. One fix applied pre-commit: a disjunction-fold comment mis-cited CR 608.2c (which governs instruction ordering, not filter disjunction) — citation removed.

Claimed parse impact

  • Vampire Nocturnus — verified end-to-end (As long as the top card of your library is black, ...TopOfLibraryMatches { HasColor(Black) } plus the anthem body).
  • Mul Daya Channelers — verified end-to-end (... is a creature card, this creature gets +3/+3.).

The condition building block recognizes the whole "top card of your library is X" family; the CI coverage-parse-diff sticky enumerates the full set of affected cards for the current head.

Validation Failures

None.

CI Failures

None.

CR 401.1 + CR 401.5: add `StaticCondition::TopOfLibraryMatches { filter }`, a
continuous-static gate that matches the top card of the source controller's
library (`library[0]`) against a characteristic `TargetFilter`. Covers the whole
"As long as the top card of your library is <X>" class: Vampire Nocturnus
(black), Mul Daya Channelers (creature/land card), Conspicuous Snoop (Goblin
card), Crown of Convergence and Chittering Illuminator (creature card), Skill
Borrower (artifact or creature card), and Oura, the Imitator (Faerie or instant
card).

- Parser: `parse_top_of_library_condition` composes the existing
  `parse_bare_predicate_disjunction` seam (shared with the recipient/attached
  "is a <filter>" paths), so bare colors, the informational " card" suffix, and
  "X or Y" disjunctions fold with no bespoke type parsing; wired into
  `parse_state_presence_conditions` in `oracle_nom/condition.rs`.
- Runtime: `layers::evaluate_condition_with_context` resolves the controller's
  library top and matches its printed characteristics (a library card matches on
  characteristics alone — no zone constraint on the filter). The `ability_rw`
  profile reads `StateKind::HandLibrary`, so a draw/scry/surveil/mill/shuffle
  re-evaluates the gate.
- Exhaustive `StaticCondition` classifiers updated in lockstep
  (object-population, entry-perturb, legacy, rw-profile, scan, coverage feature +
  formatter, unspent-mana) plus the ability- and trigger-condition bridges (no
  effect-resolution / intervening-if equivalent → `None`).

Tests: parser (color/type/subtype/disjunction), runtime (library-top read,
discriminating on filter, player scope, and empty library), and end-to-end
(Vampire Nocturnus, Mul Daya Channelers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@minion1227
minion1227 requested a review from matthewevans as a code owner July 12, 2026 19:16

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the TopOfLibraryMatches static condition to support cards that check the characteristics of the top card of a player's library (such as Vampire Nocturnus). The changes span the parser, static condition evaluation, and coverage tracking, along with comprehensive unit tests. The review feedback highlights several violations of the repository's style guide regarding the format of Magic Comprehensive Rules (CR) annotations, specifically pointing out that compound annotations (e.g., CR 401.1 + CR 401.5 or CR 401/402) do not match the required single-reference regex format.

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.

Comment thread crates/engine/src/game/ability_rw.rs
Comment thread crates/engine/src/game/layers.rs
Comment thread crates/engine/src/game/layers.rs
Comment thread crates/engine/src/game/coverage.rs
Comment thread crates/engine/src/parser/oracle_effect/conditions.rs
Comment thread crates/engine/src/parser/oracle_trigger.rs
Comment thread crates/engine/src/parser/oracle_nom/condition.rs
Comment thread crates/engine/src/parser/oracle_nom/condition.rs
Comment thread crates/engine/src/parser/oracle_nom/condition.rs
@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 8 card(s), 6 signature(s) (baseline: main 749a41f2c196)

3 card(s) · static/Continuous · field conditional: unrecognizedtop card of library is creature

Examples: Chittering Illuminator, Crown of Convergence, Mul Daya Channelers

2 card(s) · static/Continuous · field conditional: unrecognizedtop card of library is black

Examples: Vampire Nocturnus, Vampire Nocturnus Avatar

1 card(s) · static/Continuous · field conditional: unrecognizedtop card of library is Faerie or instant

Examples: Oura, the Imitator

1 card(s) · static/Continuous · field conditional: unrecognizedtop card of library is Goblin

Examples: Conspicuous Snoop

1 card(s) · static/Continuous · field conditional: unrecognizedtop card of library is artifact or creature

Examples: Skill Borrower

1 card(s) · static/Continuous · field conditional: unrecognizedtop card of library is land

Examples: Mul Daya Channelers

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: the parser and evaluator are correctly scoped, but library-top mutations do not invalidate layers, leaving these continuous effects stale.

🔴 Blocker

  • crates/engine/src/game/layers.rs:1343-1361 evaluates TopOfLibraryMatches from library.front(), while crates/engine/src/game/effects/shuffle.rs:68-77 and crates/engine/src/game/effects/change_zone.rs:153-157 shuffle that vector without calling mark_layers_full. crates/engine/src/game/zones.rs:827-846 only dirties hand/battlefield and active graveyard-membership changes; zones.rs:1094-1184 also places a card at library index 0 without invalidation. Vampire Nocturnus reads: “As long as the top card of your library is black, this creature and other Vampire creatures you control get +2/+1 and have flying.” After a shuffle, mill, or put-on-top event, that truth must be recomputed under CR 611.3a.

    Add a shared active-library-top-static dependency query at the layers/zone seam, use it for every potentially top-changing library move and shuffle helper, and call mark_layers_full when it is live.

🟡 Non-blocking

  • crates/engine/src/game/layers.rs:12164-12258 directly tests predicate truth after manual library edits, while crates/engine/src/parser/oracle_static/tests.rs:7006-7055 tests lowering only. Add production-path regressions with an active continuous static: initial matching top applies, then real shuffle, Library → Graveyard, and top-placement each flip the resulting characteristics after flush_layers.

✅ Clean

  • crates/engine/src/parser/oracle_nom/condition.rs:1415-1451 uses composable nom parsing, a terminal-boundary guard, and the existing filter vocabulary; the parse-diff measures the intended eight cards/six signatures.
  • crates/engine/src/game/layers.rs:1349-1361 reads hidden library information only inside engine evaluation and exposes only rules-mandated derived permanent characteristics.

Recommendation: request changes—wire library-top invalidation through the existing full-layer flush boundary and add the real mutation-path regressions before re-review.

@matthewevans matthewevans self-assigned this Jul 12, 2026
@matthewevans matthewevans added the feature Larger-scoped feature label Jul 12, 2026
@matthewevans matthewevans removed their assignment Jul 12, 2026
…yMatches statics

Address review on phase-rs#5692 (CR 611.3a blocker).

The TopOfLibraryMatches static is evaluated in the layer system from
`library.front()`, but library-top mutations did not force a layer recompute, so
a continuous static gated on the top card (Vampire Nocturnus: "as long as the top
card of your library is black, ... get +2/+1 and have flying") went stale after a
shuffle, mill, or put-on-top. CR 611.3a: a continuous effect from a static
ability isn't locked in — it applies to whatever its text indicates at any
moment, so the top changing must re-evaluate it.

Mirror the existing graveyard-membership invalidation pattern:
- `any_active_static_reads_top_of_library` — shared dependency query (recursing
  through And/Or/Not) for a live TopOfLibraryMatches static.
- `mark_layers_full_if_top_of_library_static_live` — single authority that
  self-gates on that query and marks layers full, so routine library churn stays
  cheap when no such static exists.

Wired into every top-changing library seam:
- `zones::move_to_zone` — new library branch (covers draw off the top, mill into
  a graveyard, and any other library entry/exit); the prior hand/battlefield and
  graveyard marks did not cover library moves.
- `zones::move_to_library_at_index` — the put-on-top path that bypasses
  move_to_zone.
- `effects::shuffle` and `effects::change_zone::shuffle_library` — after the
  reorder.

Production-path regressions (an active Flying-granting static, real mutations,
flush_layers): initial black top grants Flying; then mill-to-graveyard and
put-on-top each strip it, and a real shuffle both marks layers Full and leaves
Flying matching the recomputed top. Full engine lib suite green (16249 passed);
CI-exact clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Thanks — wired the library-top invalidation through the full-layer flush boundary and added production-path regressions (commit b700705).

🔴 Blocker — library-top mutations now invalidate layers (CR 611.3a). Mirrored the existing any_active_static_reads_zone_membership pattern:

  • any_active_static_reads_top_of_library(state) — shared dependency query (recursing through And/Or/Not) for a live TopOfLibraryMatches static.
  • mark_layers_full_if_top_of_library_static_live(state) — single authority that self-gates on that query and marks layers full, so routine library churn stays cheap.

Called at every top-changing seam you flagged:

  • zones::move_to_zone — new to == Library || from == Library branch (covers draw-off-top, mill into graveyard, and the general path the hand/graveyard marks missed).
  • zones::move_to_library_at_index — the put-on-top path that bypasses move_to_zone.
  • effects::shuffle and effects::change_zone::shuffle_library — after the reorder.

🟡 Production-path regressions (layers.rs, active Flying-granting static, real mutations, flush_layers):

  • ..._after_mill_to_graveyard — black top grants Flying; milling it away strips Flying after flush (via real move_to_zone).
  • ..._after_put_on_top — a white card put on top via move_to_library_at_index strips Flying.
  • ..._after_shuffle — a real shuffle_library marks layers Full (deterministic invalidation proof) and, after flush, Flying matches the recomputed top card, not the stale one.

Full engine lib suite green (16249 passed, 0 failed); CI-exact clippy (-p engine --all-targets --features proptest -D warnings) clean.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: library-top statics still remain stale after Scry, Surveil, and library-returning Dig reorder the top card outside the new invalidation seam.

🔴 Blocker

  • crates/engine/src/game/engine_resolution_choices.rs:580-587 handles Scry by directly retaining and inserting library cards; :1894-1903 does the same for Dig with kept_destination == Library; and :5064-5067 is the shared Surveil keep-on-top helper. None calls mark_layers_full_if_top_of_library_static_live.

    finalize_rules_state only flushes an already-dirty layer cache, so after any of these reorder paths a clean cache remains clean and a TopOfLibraryMatches static continues to use the pre-choice top card. Please make direct library reordering use one shared invalidating primitive, or mark through the helper after all three paths, and add production-path regressions with an active top-gated static.

🟡 Non-blocking

  • None.

✅ Clean

  • crates/engine/src/game/layers.rs:2278-2324 correctly detects nested And/Or/Not top-of-library gates and self-gates the full invalidation.
  • crates/engine/src/game/zones.rs:856-857 plus both shuffle helpers cover ordinary library zone moves and shuffle paths.
  • The parse-diff artifact measures the intended eight cards across six signatures; the follow-up head changes only runtime invalidation files.

Recommendation: request changes—cover the remaining Scry, Surveil, and Dig reordering paths with the same shared invalidation authority, then re-review.

@matthewevans matthewevans added enhancement New feature or request and removed feature Larger-scoped feature labels Jul 12, 2026
Second review round on phase-rs#5692. The first fix covered the zone-move and shuffle
seams, but Scry, Surveil, and library-returning Dig reorder the top card by
editing `player.library` directly (retain + insert), bypassing `move_to_zone`.
`finalize_rules_state` only flushes an already-dirty layer cache, so a clean
cache survived these reorders and a `TopOfLibraryMatches` static kept using the
pre-choice top card (CR 611.3a — the effect isn't locked in).

Route all three direct-reorder paths through the same shared invalidation
authority added in the previous commit:
- Scry keep-on-top (`ScryChoice` resolution).
- Dig with `kept_destination == Library` (kept cards placed on top).
- `surveil_keep_on_top` — the shared helper backing every Surveil caller.

Production-path regressions drive the real resolution (`handle_resolution_choice`
for Scry/Dig, the shared helper for Surveil) with an active Flying-granting
top-gated static: an initial black top grants Flying, then keeping a white card
on top via each path strips it after `flush_layers`. Each card stays in the
library (no zone change), so the new invalidation call is the only thing that can
force the recompute — the tests fail without it. Full engine lib suite green
(16252 passed); CI-exact clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Thanks for catching the direct-reorder paths — covered all three (commit 237cbbc), routing them through the same shared invalidation authority from the first round.

🔴 Scry / Surveil / Dig now invalidate (CR 611.3a):

  • engine_resolution_choices.rs Scry keep-on-top (ScryChoice resolution) → mark_layers_full_if_top_of_library_static_live after the retain/insert reorder.
  • Dig with kept_destination == Library (kept cards placed on top) → same, right after the top edit.
  • surveil_keep_on_top — the shared helper backing every Surveil caller → same, at the end of the helper.

Production-path regressions (drive the real resolution — handle_resolution_choice for Scry/Dig, the shared helper for Surveil — with an active Flying-granting top-gated static):

  • scry_reorders_top_and_reevaluates_top_gated_static
  • surveil_keep_on_top_reevaluates_top_gated_static
  • dig_kept_to_library_top_reevaluates_top_gated_static

Each keeps a white card on top and asserts Flying is recomputed away after flush_layers. In all three the cards stay in the library (no zone change), so the new invalidation call is the only thing that can force the recompute — the tests fail without it.

Full engine lib suite green (16252 passed, 0 failed); CI-exact clippy (-p engine --all-targets --features proptest -D warnings) clean.

@matthewevans matthewevans self-assigned this Jul 12, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — the eight-card, six-signature top-of-library static class is implemented at the parser/layer seam, with all live library-top mutation paths invalidating the cache.

🔴 Blocker

  • None.

🟡 Non-blocking

  • The remaining Gemini threads request replacing compound CR citations. Refuted: this repository explicitly permits compound CR X + CR Y and CR X / CR Y annotations; the cited rules are present in docs/MagicCompRules.txt (401.5 covers a changing library top and 611.3a makes static continuous effects live rather than locked in). The comments are resolved without a code change.

✅ Clean

  • oracle_nom/condition.rs:1415 composes the existing bare-predicate parser into a typed TopOfLibraryMatches condition; the current parse artifact is exactly eight cards across six signatures.
  • layers.rs:2278 provides the shared recursive dependency query and mark_layers_full_if_top_of_library_static_live; ordinary zone moves, both shuffle seams, and direct Scry/Surveil/Dig reorders route through it.
  • engine_resolution_choices.rs:5190, :5217, and :5233 exercise real resolution paths with an active static and prove the effect is removed after flush_layers when the top card becomes white.

Recommendation: approve and enqueue as enhancement; all current-head blockers are resolved.

@matthewevans
matthewevans added this pull request to the merge queue Jul 12, 2026
@matthewevans matthewevans removed their assignment Jul 12, 2026
Merged via the queue into phase-rs:main with commit 1cc5e31 Jul 13, 2026
13 checks passed
@minion1227
minion1227 deleted the minion_top_of_library_condition branch July 18, 2026 06:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants