Skip to content

feat(engine,server): wire production trigger matchers, draft match spawn, and planechase scaffold - #2113

Merged
matthewevans merged 8 commits into
phase-rs:mainfrom
claytonlin1110:feat/mega-features-triggers-draft-planechase
Jun 4, 2026
Merged

feat(engine,server): wire production trigger matchers, draft match spawn, and planechase scaffold#2113
matthewevans merged 8 commits into
phase-rs:mainfrom
claytonlin1110:feat/mega-features-triggers-draft-planechase

Conversation

@claytonlin1110

Copy link
Copy Markdown
Contributor

Summary

First implementation pass on the mega-features phased roadmap: not full plan completion, but the highest-leverage slices across Phases 1–4.

  • Phase 1: Coverage inventory in docs/phase1-coverage-inventory.md (86.89% corpus; cluster chore: update coverage stats and badges #1 = Effect:static_structure + Effect:unknown, 35 cards). Parser intercept for standalone specialize {cost}Keyword::Specialize (15-card pattern).
  • Phase 2 (batch A/B): Production matchers + registry/index for Discover, Adapt, Investigated, Forage, Foretell, Exerted, PayLife, DamagePreventedOnce. New events: GameEvent::Foretold, PlayerActionKind::Forage. Removed duplicate stub registration for modes that already had matchers in trigger_matcher().
  • Phase 3: Spree per-mode costs already handled by compute_modal_total_cost — no runtime change. BeginGame/Class/dungeon rooms largely pre-existing or still deferred (BG Wilderness rooms 7–8, 10–14 remain Unimplemented).
  • Phase 4: PlanechaseState + GameState.planechase scaffold (planar_deck, active plane, controller). Draft: ensure_pairings_generated + spawn_match_games_for_round + maybe_spawn_draft_matches sends DraftMatchStart when the pod enters match play.

Out of scope (follow-up PRs): ~30 remaining stub TriggerModes, full planechase die/planeswalk/chaos, bulk Effect:unknown (420 single-gap cards), mtgish-import.

Test plan

  • cargo fmt --all
  • cargo check -p engine -p server-core -p phase-server
  • cargo test -p engine trigger_matchers:: (194 passed)
  • Tilt: engine + phase-server resources (per CONTRIBUTING)
  • Optional: scripts/coverage-regression-check.sh after card-data regen if specialize unlock is measured
  • Manual: Premier draft pod → all submit decks → verify DraftMatchStart + game session in active_matches
  • Manual: card with “specialize {2}” line shows supported keyword in coverage overlay

Files touched (high level)

Area Files
Triggers trigger_matchers.rs, trigger_index.rs, types/triggers.rs, types/events.rs
Runtime casting.rs, effects/mod.rs, log.rs, public_state.rs
Parser oracle.rs
Planechase game/planechase.rs, game_state.rs
Draft server draft_session.rs, phase-server/main.rs
Docs docs/phase1-coverage-inventory.md

@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 introduces Planechase planar deck scaffolding, implements several new trigger matchers (including Discover, Adapt, Forage, and Foretell), adds parsing support for the Specialize keyword, and wires up server-side draft match spawning. Feedback on these changes highlights a critical issue where a single missing deck submission can block all draft matches from spawning, and notes that match_pay_life incorrectly triggers on any life loss rather than specific life payments. Additionally, several style guide violations were identified, including the use of string prefix checks instead of nom combinators for parser dispatch (Rule R1), incorrect Comprehensive Rules (CR) references for Forage, and a missing CR annotation for Forage resolution (Rule R6). Finally, it is recommended to log errors when pairing generation fails during draft match spawning.

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/server-core/src/draft_session.rs Outdated
Comment on lines +471 to +485
let deck_payloads: Result<Vec<_>, String> = pairing
.players
.iter()
.map(|pid| {
let seat = pid.0 as usize;
let submission = session.session.submitted_decks.get(pid).ok_or_else(|| {
format!(
"seat {} has not submitted a deck for match {}",
seat, pairing.match_id
)
})?;
deck_payload_from_submission(db, submission)
})
.collect();
let decks = deck_payloads?;

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.

high

[HIGH] Gracefully handle missing deck submissions to avoid blocking other matches.

Why it matters: Using the ? operator on deck_payloads will abort the entire spawn_match_games_for_round function if any single player in any pairing has not yet submitted their deck, preventing all subsequent ready pairings from spawning their games.

Suggested fix: Log a warning and continue the loop for that pairing instead of returning early.

            let deck_payloads: Result<Vec<_>, String> = pairing
                .players
                .iter()
                .map(|pid| {
                    let seat = pid.0 as usize;
                    let submission = session.session.submitted_decks.get(pid).ok_or_else(|| {
                        format!(
                            "seat {} has not submitted a deck for match {}",
                            seat, pairing.match_id
                        )
                    })?;
                    deck_payload_from_submission(db, submission)
                })
                .collect();

            let decks = match deck_payloads {
                Ok(d) => d,
                Err(e) => {
                    warn!(match_id = %pairing.match_id, error = %e, "cannot spawn match yet");
                    continue;
                }
            };

Comment on lines +2546 to +2553
let GameEvent::LifeChanged { player_id, amount } = event else {
return false;
};
if *amount >= 0 {
return false;
}
valid_player_matches(trigger, state, *player_id, source_id)
}

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.

high

[HIGH] match_pay_life incorrectly triggers on any life loss.

Why it matters: Under CR 119.3 and CR 118.4, paying life is a specific action/cost. Triggering on any negative LifeChanged event will incorrectly fire "Whenever you pay life" triggers for regular damage or loss of life effects, violating strict CR fidelity (Pillar 2).

Suggested fix: Distinguish life payments from other life loss events, or add a TODO/guard to prevent false positives from damage/loss of life.


// Digital-only Specialize: "specialize {cost}" — MTGJSON may omit the keyword
// when it appears as a standalone rules line; intercept before dispatch fallback.
if lower_starts_with(&lower, "specialize ") {

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.

medium

[MEDIUM] Avoid using lower_starts_with for parsing dispatch.

Why it matters: Rule R1 of the style guide strictly prohibits using full-string starts_with/contains checks for parsing dispatch in non-test parser code, requiring nom 8.0 combinators instead.

Suggested fix: Use a nom combinator to match the "specialize" prefix.

References
  1. Every new parser dispatch under crates/engine/src/parser/ must use nom 8.0 combinators or delegate to existing helpers. Full-string starts_with/contains checks are prohibited. (link)
  2. Avoid verbatim string equality for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts (e.g., subjects, conjunctions) and compose them using idiomatic combinator aggregates (like nested alt and tag sequences) to prevent combinatorial explosion and improve maintainability.

Comment thread crates/engine/src/types/events.rs Outdated
Comment on lines +92 to +93
/// CR 702.808: A player foraged (exiled cards from graveyard for food).
Forage,

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.

medium

[MEDIUM] Correct the CR rule reference for Forage.

Why it matters: The Comprehensive Rules section for the keyword action Forage is CR 701.50, not CR 702.808 (which is a hallucinated rule number). This violates Rule R6 of the style guide.

Suggested fix: Update the comment to reference CR 701.50.

Suggested change
/// CR 702.808: A player foraged (exiled cards from graveyard for food).
Forage,
/// CR 701.50: A player foraged (exiled cards from graveyard or sacrificed a Food).
Forage,
References
  1. Every rules-touching line of engine code must carry a verified CR comment. (link)

Comment on lines +2498 to +2499
/// CR 702.808: Forage — fires when a player forages.
pub(super) fn match_forage(

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.

medium

[MEDIUM] Correct the CR rule reference for Forage.

Why it matters: The Comprehensive Rules section for the keyword action Forage is CR 701.50, not CR 702.808. This violates Rule R6 of the style guide.

Suggested fix: Update the comment to reference CR 701.50.

Suggested change
/// CR 702.808: Forage — fires when a player forages.
pub(super) fn match_forage(
/// CR 701.50: Forage — fires when a player forages.
References
  1. Every rules-touching line of engine code must carry a verified CR comment. (link)

Comment thread crates/engine/src/game/effects/mod.rs Outdated
Comment on lines +1840 to +1844
Effect::Forage => {
// This keyword action is recognized by the parser but not yet implemented.
// It's a no-op at runtime but counts as supported for coverage.
events.push(GameEvent::PlayerPerformedAction {
player_id: ability.controller,
action: PlayerActionKind::Forage,
});

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.

medium

[MEDIUM] Add mandatory CR annotation for Forage resolution.

Why it matters: Every rules-touching line of engine code must carry a verified CR comment under Rule R6 of the style guide.

Suggested fix: Add a comment referencing CR 701.50.

Suggested change
Effect::Forage => {
// This keyword action is recognized by the parser but not yet implemented.
// It's a no-op at runtime but counts as supported for coverage.
events.push(GameEvent::PlayerPerformedAction {
player_id: ability.controller,
action: PlayerActionKind::Forage,
});
Effect::Forage => {
// CR 701.50: To forage is to exile three cards from your graveyard or sacrifice a Food.
events.push(GameEvent::PlayerPerformedAction {
player_id: ability.controller,
action: PlayerActionKind::Forage,
});
References
  1. Every rules-touching line of engine code must carry a verified CR comment. (link)

Comment thread crates/phase-server/src/main.rs Outdated
Comment on lines +2010 to +2012
if draft_mgr.ensure_pairings_generated(draft_code).is_err() {
return;
}

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.

medium

[MEDIUM] Log errors when generating pairings fails.

Why it matters: Silently returning on error makes debugging difficult when pairings fail to generate during match spawning.

Suggested fix: Log the error using warn!.

        if let Err(e) = draft_mgr.ensure_pairings_generated(draft_code) {
            warn!(draft = %draft_code, error = %e, "failed to generate pairings");
            return;
        }

@matthewevans

Copy link
Copy Markdown
Member

🤖 Architecture Review (automated)

Verdict: ⚠️ Changes requested
Branch vs origin/main: updated via update-branch

Seam: PASS — trigger matchers / index / events live in the engine; draft-spawn logic correctly sits in server-core + phase-server; the parser intercept reuses parse_keyword_from_oracle and the established lower_starts_with dispatch idiom (consistent with adjacent suspend/harmonize intercepts).
Idiomatic: CONCERN — match_pay_life is a byte-for-byte clone of match_life_lost (DRY) and PlanechaseState is dead scaffold with no consumers.
Value: Strong for triggers — wires 7 real TriggerMode matchers (Discover/Adapt/Investigated/Forage/Foretell/PayLife/DamagePreventedOnce) to already-emitted events, advancing whole classes. Planechase slice adds no runtime behavior.

The trigger-matcher wiring is the high-value core and is mostly correct: I verified EffectResolved{Adapt} (adapt.rs:56), {Discover} (discover.rs:56), PlayerPerformedAction{Investigate} (investigate.rs:49) / {Forage} (new effects/mod.rs), Foretold (new casting.rs), and DamagePrevented (combat_damage.rs:880 +2) are all genuinely emitted — no parsed-but-unused matchers. Issues below are CR-citation accuracy + two semantic/dead-code items.

Findings

  • [HIGH] trigger_matchers.rs match_adapt doc — CR 702.12a is Indestructible, not Adapt. Adapt is CR 701.46a (already correctly cited in effects/adapt.rs:56). Fix the annotation to 701.46a.
  • [HIGH] trigger_matchers.rs match_forage doc + events.rs PlayerActionKind::ForageCR 702.808 does not exist. Forage is CR 701.61. Fix both annotations.
  • [HIGH] game/planechase.rs (module doc, planar_controller, active_plane_index) — Planechase is CR 901 (309 = Dungeons, 311 = Planes). 901.5 = starting plane, 901.6 = planar controller. The cited CR 309 / 311 / 309.4 / 311.2 are all wrong. Re-cite against CR 901.
  • [HIGH] trigger_matchers.rs match_pay_life — fires on any LifeChanged { amount < 0 }, identical to match_life_lost. "Pay life" (CR 119.4, life paid as a cost) is narrower than "lose life": this will over-fire on combat damage, drain, and Phyrexian-mana-unrelated loss. Either gate on a cost-payment signal or, if no such signal exists yet, defer PayLife rather than alias it to life-loss. At minimum it should not duplicate match_life_lost verbatim.
  • [MEDIUM] game/planechase.rs + game_state.rs:planechasePlanechaseState, new(), and active_plane_name() have zero consumers anywhere; state.planechase is never read/written. Pure dead scaffold. Consider dropping until a consumer lands, or keep but it adds a serialized field with no behavior.
  • [MEDIUM] trigger_matchers.rs match_damage_prevented_onceDamagePrevented.source_id is the damage source (events.rs:329), not the preventer; the local binding name preventer and *preventer == source_id self-match are misleading and likely wrong for "prevent... when you do" triggers whose source is the preventing permanent. Low card-count, but the comparison semantics need verifying against a real DamagePreventedOnce card.
  • [NIT] match_pay_life doc cites CR 119.3 (life adjustment); the cost concept is CR 119.4 ("pay life").

@matthewevans

Copy link
Copy Markdown
Member

🏛️ Architecture & Idiom Deep-Dive (automated)

Verdict: ♻️ Refactor recommended
Adversarial idiom pass — does NOT re-check correctness; see the prior architecture-review comment for that.

What's idiomatic here: The trigger-index rework is the right architectural move — Discover/Adapt/Exerted/Foretell graduate from the "unclassified, checked against every event" bucket into dedicated TriggerEventKeys wired symmetrically across keys_from_trigger_def / keys_from_event / keys_from_effect_kind, with the event-emission seam (Foretold, PlayerPerformedAction::Forage) added at the resolver. The new matchers use the house let-else idiom, and the specialize parser interception is nom-backed (lower_starts_withtag().parse()) and consistent with its suspend/harmonize siblings.

Idiom / architecture findings

  • [REUSE] crates/engine/src/game/trigger_matchers.rs:451 (match_pay_life) — this is a verbatim logic clone of the existing match_life_lost (:1699): both destructure LifeChanged, bail on amount >= 0, then valid_player_matches. Since GameEvent::LifeChanged carries no paid-vs-lost cause discriminator, the new function is behaviorally indistinguishable from match_life_lost and adds nothing. → fix: drop match_pay_life and route TriggerMode::PayLife to match_life_lost — the dispatch arm at :52 already groups LifeLost | LifeLostAll => match_life_lost; just add | TriggerMode::PayLife. (If a true "paid as a cost" distinction is intended, it needs a cause field on the event, not a duplicate matcher — leave a TODO rather than a clone.)

  • [REUSE] crates/engine/src/game/trigger_matchers.rs match_investigated / match_forage — both re-destructure PlayerPerformedAction and check one PlayerActionKind variant, duplicating the single authoritative dispatcher match_player_action (:1728), which already owns exactly this job for Scry/Surveil/CollectEvidence/SearchedLibrary via trigger.modePlayerActionKind arms. Both new modes route through the same PlayerActionPerformed index key match_player_action consumes, so routing is fully compatible. → fix: delete both functions, add two arms to match_player_action's inner match — TriggerMode::Investigated => *action == PlayerActionKind::Investigate and TriggerMode::Forage => *action == PlayerActionKind::Forage — and register Investigated/Foragematch_player_action in both trigger_matcher and build_trigger_registry (alongside Scry, Surveil).

  • [POLISH] crates/engine/src/game/planechase.rs + types/game_state.rs:5081PlanechaseState, the GameState.planechase field, and active_plane_name() have zero call sites: the field is only ever set to None in the constructor, nothing reads it, and active_plane_name() / new() are never invoked. This is speculative scaffolding ahead of any consumer ("Just-In-Time"/"Question Necessity"). → fix: land the PlanechaseState shape in the same PR that first reads/writes it, rather than as a standalone stub. At minimum drop the dead active_plane_name() helper until something calls it.

The two REUSE items are the load-bearing ones: they add new matcher functions where the codebase already has the authoritative dispatcher for that event class, which is the exact "verbatim copy of a sibling matcher" smell. Reshaping now keeps the matcher table from accreting one-function-per-keyword duplication that future keywords will copy.

@matthewevans

Copy link
Copy Markdown
Member

🔁 Re-review (upgraded process)

Prior verdict: ⚠️ Changes requested → Revised: ⚠️ Changes requested (unchanged verdict; one HIGH the automated pass missed)

Reconciled with existing reviews:

  • Arch-review HIGH match_adapt doc CR 702.12a → should be 701.46 — CONFIRMED (701.46 = Adapt; 701.12 = Exchange, 702.12 = Indestructible; verified docs/MagicCompRules.txt:3679).
  • Arch-review HIGH match_forage/PlayerActionKind::Forage doc CR 702.808 (hallucinated) → 701.61 — CONFIRMED (docs/MagicCompRules.txt:3807). NOTE: Gemini's suggested 701.50 is wrong — 701.50 is Connive; use 701.61.
  • Arch-review HIGH game/planechase.rs CR 309/311/309.4/311.2 → should be CR 901 — CONFIRMED (309 = Dungeons, 311 = Planes; Planechase variant is 901; 901.5 = starting plane, 901.6 = planar controller). Re-cite all four.
  • Arch-review HIGH + Gemini HIGH match_pay_life over-fires on any LifeChanged{amount<0} — CONFIRMED. It is a verbatim semantic clone of match_life_lost (trigger_matchers.rs:1691: same LifeChanged + amount>=0 → false + valid_player_matches). "Pay life" (CR 119.4) is life paid as a cost, narrower than "lose life" (CR 119.3) — this fires on combat damage, drain, and life-loss effects. The matcher is also currently unreachable (no parser path produces TriggerMode::PayLife — grep of parser/ is empty), but per severity calibration that is evidence, not a downgrade: the day a "whenever you pay life" trigger is wired to this mode it silently mis-fires for the whole class. Gate on a cost-payment signal or defer the mode rather than alias life-loss.
  • Arch-review MED PlanechaseState dead scaffold — CONFIRMED. state.planechase is never read/written; new()/active_plane_name() have zero consumers. Adds a serialized field with no behavior.
  • Arch-review MED match_damage_prevented_once preventer naming — CONFIRMED. DamagePrevented.source_id is the damage source (events.rs:329), not the preventing permanent, so let preventer = source_id and *preventer == source_id are mislabeled and likely wrong for "prevent damage… when you do" triggers.
  • Arch-review NIT match_pay_life doc CR 119.3 → 119.4 — CONFIRMED (119.4 = pay life; 119.3 = life adjustment).
  • Gemini MED lower_starts_with for the new specialize intercept — REFUTED as a delta. This is the established keyword-line dispatch idiom in oracle.rs (suspend/harmonize/flashback/escape all use lower_starts_with, lines 1123-1133); the new line mirrors them exactly. The nom-mandate applies to new effect/structural dispatch, not pre-existing keyword-interception parity. Not a regression introduced here.

Missed or re-rated

  • [HIGH] crates/server-core/src/draft_session.rs:771 (let decks = deck_payloads?;) — the automated architecture review missed this entirely (Gemini caught it). The ? on the collected Result<Vec<_>> aborts the whole spawn_match_games_for_round on the first pairing whose player hasn't submitted a deck, and the caller maybe_spawn_draft_matches turns that Err into warn!; return. Effect: one un-submitted deck in any pairing blocks every ready pairing in the round from spawning its game — a real availability bug in the headline draft-match feature, not a cosmetic one. Fix: convert the per-pairing deck resolution into a match { Ok(d) => d, Err(e) => { warn!(...); continue; } } so only the incomplete pairing is skipped.

matthewevans and others added 2 commits June 3, 2026 15:52
…iggers-draft-planechase

# Conflicts:
#	crates/engine/src/game/trigger_matchers.rs
@claytonlin1110
claytonlin1110 force-pushed the feat/mega-features-triggers-draft-planechase branch from 99aea3c to 051bbd6 Compare June 3, 2026 22:55
…ers-draft-planechase' into feat/mega-features-triggers-draft-planechase

# Conflicts:
#	crates/engine/src/game/effects/mod.rs
#	crates/engine/src/game/trigger_index.rs
#	crates/engine/src/game/trigger_matchers.rs
#	crates/engine/src/types/game_state.rs
#	crates/engine/src/types/triggers.rs
#	crates/phase-server/src/main.rs
#	crates/server-core/src/draft_session.rs
@matthewevans
matthewevans force-pushed the feat/mega-features-triggers-draft-planechase branch from 06b76fa to ae0a6ae Compare June 4, 2026 01:12
@matthewevans
matthewevans force-pushed the feat/mega-features-triggers-draft-planechase branch from ae0a6ae to aa9e505 Compare June 4, 2026 01:18
@matthewevans

Copy link
Copy Markdown
Member

Cleanup pass pushed to the original fork branch.

Changes I made:

  • Removed the remaining fake Forage diff from this PR. The current PR diff has no added/removed Forage/forage lines relative to origin/main.
  • Kept the planechase scaffold out of the trimmed branch. The current PR diff has no added/removed Planechase/planechase lines and does not add the planechase module/state/public-state plumbing.
  • Fixed the CI failures from the cleanup push:
    • replaced the Option::map(... sender.send(msg)) pattern with an if let so clippy no longer sees a large Err return path,
    • removed needless borrows at the maybe_spawn_draft_matches callsite,
    • updated the GeneratePairings rejection test to match the new server-internal rejection reason.

Current state:

  • PR head: aa9e505cdb64c45f5777de693a9fbf22f2668df4
  • Diff size: 11 files, +423/-56
  • GitHub reports merge state CLEAN
  • CI is green, including Rust lint, Rust tests, card data, WASM, Tauri, frontend, lobby, and the final Rust rollup.

Assumption: “fake Forage” means this PR should not change Forage relative to origin/main; I did not remove any Forage support that already exists on main.

@matthewevans
matthewevans added this pull request to the merge queue Jun 4, 2026
Merged via the queue into phase-rs:main with commit 8d87fd9 Jun 4, 2026
10 checks passed
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.

2 participants