feat(engine): combo-detection PR-0 — ResourceVector + modulo-resource loop equality (additive, no behavior change) - #4092
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
🤖 AI text below 🤖 Design context: where PR-0 sits in the infinite-combo-detection planThis PR is the first slice of a larger, staged effort. The full design lives in two planning docs that are not in the source tree (they're local/untracked), so this comment summarizes their scope and the PR roadmap so the change has context for review. Happy to land the docs under GoalShip an infinite-combo confirmation tool: given a board (or card set), verify a claimed deterministic loop and report what resource goes unbounded (damage, mana, tokens, proliferate triggers, mill, …). This enables three downstream wins — auto-shortcutting loops in live play per CR 732, an AI that recognizes/executes its own winning lines, and a UI that renders Why this is a narrow extension, not greenfieldThe engine already detects and draws repeating mandatory loops (CR 104.4b / 732.4) via The design is deliberately sound-but-incomplete and resource-bounded: Magic is Turing-complete (arXiv:1904.09828), so no detector can be complete. It never falsely confirms a loop; it confirms the deterministic resource-loop class that dominates real play. Theory basis: combo = directed-graph cycle detection (Tarjan SCC); "what goes infinite" = Petri-net / VASS coverability; confirmation = bounded model checking via a state fixpoint modulo monotone resources — which is precisely what this PR's Acceptance suite (the spec's definition of done)3 driving combos + a card-disjoint 50-combo corpus (113 distinct cards, all verified present in PR roadmap
The corpus-passing milestone is PR-0 + PR-1 + PR-2 (~500–900 net-new LOC, offline, zero gameplay change, validated by 49 concrete tests). PR-3 adds the live shortcut; PR-4 (the only high-churn item) is explicitly not required to pass the corpus. Each cluster is independently reviewable, and clusters 0–2 add zero game-behavior change. What PR-0 specifically contributes to that arc
|
|
@matthewevans I'm going to do this one with humans in the loop, |
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for splitting this out as PR-0. I agree with the direction of having a separate resource-projected equality for beneficial-loop analysis, but I don't think this base predicate is safe yet.
The blocker is that loop_states_equal_modulo_resources currently projects out every object counter (object.counters.clear()), while CounterClass::Other deliberately folds the long-tail counter kinds together. In this engine, several of those long-tail counters are not merely monotone resources: stun counters prevent untapping and are consumed during untap (CR 122.1d), shield counters are consumed by destruction/damage replacement effects and can gate continuous control effects, lore counters drive Saga chapter/SBA behavior, and time counters drive suspend/impending/vanishing-style duration behavior. Because all of those are erased by the projection, a loop cycle that consumes one of those finite counters while producing damage/mana/etc. can compare as the “same board + net resource,” even though the cycle is not repeatable after the counter is gone. That is a false positive in the equality primitive that PR-1/PR-2 would trust.
Concrete code evidence:
crates/engine/src/analysis/resource.rs:88-100classifies all long-tail counters asOther.crates/engine/src/analysis/resource.rs:542-548clears every object's counters before equality.- Existing engine semantics show these counters affect future legality/state:
CounterType::Stundocuments/removes on untap incrates/engine/src/types/counter.rs:26-35andcrates/engine/src/game/turns.rs:903-914; shield counters are consumed by destruction incrates/engine/src/game/effects/destroy.rs:643-659and can gate control duration incrates/engine/src/game/effects/gain_control.rs:441-465.
Please narrow the projection to counters that are actually safe monotone resource axes, or make the counter classification preserve enough semantics that consumable/duration/state-gating counters remain part of loop-state equality. Add a regression where state B has a consumed stun/shield/time/lore-style counter plus a positive resource delta and prove loop_states_equal_modulo_resources rejects it.
There's also a smaller correctness gap in is_net_progress: the module docs explicitly list “mill 1 more card” as a beneficial loop resource, and unbounded_components reports negative library_delta, but is_net_progress only sets progress when some component is > 0 (resource.rs:333-360). A pure mill loop with only library_delta = -N is therefore not net progress. If the intended contract is that mill can be the unbounded resource, this should be fixed and covered with a direct test; if PR-1 is supposed to add a separate positive event axis for mill, the contract here should not claim negative library delta itself can satisfy progress.
…nt-modulo-resources (no behavior change) Add a purely-additive `engine::analysis` module as the measurement substrate for the infinite-combo detector. No game behavior changes: no reducer, SBA, or resolution-loop path is touched. - `ResourceVector`: the monotone resource axes a net-progress loop can pump (mana, life, damage, library size, tokens, draws, casts, landfall/combat/turn counts, death/etb/ltb/sac triggers, counters keyed by (CounterClass, ObjectClass), generic triggers keyed by TriggerKind). `snapshot()` reads the state-readable levels (mana/life/library/counters) directly out of a GameState; event-fed fields are left zero for the PR-1 harness to feed. `delta()`, `is_net_progress()`, and `unbounded_components()` classify a cycle. - `loop_states_equal_modulo_resources()`: the complement of the existing strict CR 104.4b `loop_states_equal` — board/zones/tap identical, monotone resources allowed to differ (CR 732.2a net-progress shortcut). Built on the existing `normalize_for_loop` + an additional resource projection; no new fingerprint, no visibility changes to the reused `pub(crate)` helpers. CR annotations grep-verified against docs/MagicCompRules.txt. Assisted-by: ClaudeCode:claude-opus-4.8
…net-progress Addresses CHANGES_REQUESTED on PR phase-rs#4092 (combo-detection PR-0). Blocker 1: project_out_resources cleared ALL object counters before the resource-modulo loop-equality, erasing consumable/duration/state-gating counters (CR 122.1c shield, CR 122.1d stun, CR 714.3 lore, CR 702.62a/63a time, CR 702.32a fade, CR 702.24a age) that are NOT monotone resources. A cycle that consumes one of those finite counters while pumping damage/mana then compared as "same board + net resource" — a false-positive net-progress loop. Replace the blanket clear() with a principled exhaustive partition: CounterType::is_monotone_loop_resource() classifies every variant (P/T per CR 122.1a/613.4c, loyalty CR 306.5b, defense CR 310.4c are monotone and projected out; the consumable/duration/state-gating tail is preserved in objects_content_eq's counter comparison). Exhaustive no-wildcard match so a future CounterType variant is a compile error. Derived power/toughness/ loyalty/defense stay zeroed safely: they fold only monotone counters (power_toughness_delta()==Some / Loyalty / Defense), so a preserved non-monotone counter still distinguishes two boards via the counters map. Blocker 2: is_net_progress only counted strictly-positive components, so a pure mill loop (only a negative library_delta) was not net-progress despite unbounded_components surfacing it as a win axis. Add a negative-library clause (CR 121.4 / CR 704.5b: emptying a library wins on the next attempted draw). Strictly additive — the consumed-axis sustainability guard still fires first, and positive library growth is already counted. Also corrects a pre-existing wrong CR annotation on the Lore variant doc (714.1 is Saga card layout, not lore mechanics -> 714.3 + 714.4). Discriminating tests: a consumed shield counter (2->1) plus a projected-out resource gain stays modulo-UNEQUAL while a monotone +1/+1 plus the same gain stays modulo-EQUAL; a pure-mill delta is net-progress while a mill that net-loses life is rejected; the partition is asserted exhaustively. Each fails if its fix is reverted (verified). Purely additive, no game-behavior change, no wiring. Assisted-by: ClaudeCode:claude-opus-4.8
edf834c to
27ef23d
Compare
|
🤖 AI text below 🤖 Rebased onto current Addressed the review via the full New head: |
matthewevans
left a comment
There was a problem hiding this comment.
VERDICT: approve
Re-reviewed current head 27ef23d after the follow-up. The two prior blockers are addressed: project_out_resources now only projects counters classified by CounterType::is_monotone_loop_resource() and preserves shield/stun/time/lore/generic-style counters, with a shield-consumption negative test plus a +1/+1 positive control; ResourceVector::is_net_progress() now recognizes pure negative library_delta as mill progress, with empty-delta and consumed-life controls.
Repush guard held immediately before approval, the required enhancement label is present, merge state is CLEAN, and the current-head GitHub checks are green.
🤖 AI text below 🤖
PR-0 of the infinite-combo detector — the measurement substrate. See the design concept in the combo-detection plan (
.planning/combo-detection/IMPLEMENTATION.md§5/§7;FEASIBILITY-AND-PLAN.md§11 anchors). This PR is purely additive: a newengine::analysismodule of types + a comparison function + tests. No game-behavior change — no reducer, SBA, or resolution-loop path is touched.What was added
New module
crates/engine/src/analysis/(mod.rs+resource.rs), wired viapub mod analysis;incrates/engine/src/lib.rs.ResourceVector— the monotone resource axes a net-progress loop can pump:mana: [i64; 6](W/U/B/R/G/C), per-playerlife/damage_dealt/library_delta(BTreeMap<PlayerId, i64>),tokens_created,cards_drawn,casts_this_step,landfall_triggers,combat_phases,extra_turns,death_triggers/etb_triggers/ltb_triggers/sac_triggers,counters: BTreeMap<(CounterClass, ObjectClass), i64>(incl. +1/+1, loyalty, poison, energy),generic_triggers: BTreeMap<TriggerKind, i64>(proliferate / magecraft / …).snapshot(&GameState)reads the state-readable levels directly (floating mana, life, library sizes, object/player counters). Event-fed fields (damage, tokens, draws, casts, all*_triggers,generic_triggers) are left atDefaultfor the PR-1 harness to feed — they are events, not totals a singleGameStateretains.delta(before, after)(component-wise subtraction),is_net_progress()(≥1 component strictly > 0 and no consumed axis — mana/life — net-negative), andunbounded_components()→ the typedResourceAxistags for laterWinKindclassification.loop_states_equal_modulo_resources(a, b)— the complement of the existing strict CR 104.4bloop_states_equal: board/zones/tap-state identical, but the monotone resources allowed to differ (CR 732.2a net-progress shortcut vs CR 104.4b / CR 732.4 mandatory draw). Built directly on the existingnormalize_for_loop(inheriting its volatile-field exclusions) plus an additional resource projection, then delegating toloop_states_equal. No new fingerprint; reusesloop_fingerprint/normalize_for_loop/loop_states_equalwith no visibility changes to thosepub(crate)helpers (reachable fromanalysiswithin the engine crate).Helper enums (typed, not stringly)
ObjectClass { Creature, Planeswalker, Battle, Player, Other }— what kind of thing a counter accumulates on.CounterClass { Plus1Plus1, Minus1Minus1, Loyalty, Defense, Poison, Energy, Other }— analysis-layer counter classification.TriggerKind { Proliferate, Magecraft, Constellation, Landfall, Other }— generic trigger families.ResourceAxis— the tagged identity of one unbounded resource (consumed by the PR-2WinKindclassifier).§5 deferral note
The spec's
counterskey was(CounterType, ObjectClass). The engine'sCounterTypederives neitherOrd(required forBTreeMapkeys) nor a small closed set (it carriesGeneric(String),Keyword(KeywordKind), parameterizedPowerToughness). AddingOrdto that crate-wide enum (and transitivelyKeywordKind) to satisfy one analysis map would be a far larger, non-additive change — so this module owns a smallOrdCounterClassand mapsCounterType → CounterClassat the snapshot boundary. All §5 axes are present; only this key-type substitution differs, documented in code.add-engine-variant verdict: N/A
ResourceVector/ObjectClass/CounterClass/TriggerKind/ResourceAxisare analysis types, not ability/Effect enum variants.data/engine-inventory.jsonis unchanged (confirmed: nocargo engine-inventorychurn).CR annotations (grep-verified against
docs/MagicCompRules.txt)Existence + description verified for every CR number, e.g.: CR 104.4b (mandatory-loop draw), CR 732.2a / CR 732.4 (shortcut vs draw), CR 106.1 (mana), CR 119.1 (life), CR 120.1 (damage), CR 121 (draw), CR 122.1 / 122.1a (counters), CR 306.5b (loyalty), CR 310.4c (defense), CR 401 (library), CR 500.7 (extra turns), CR 500.8 + 506 (extra combat), CR 603.6a/603.6c (ETB/LTB), CR 700.4 (dies), CR 701.21 (sacrifice), CR 701.34 (proliferate), CR 207.2c + 603 (magecraft / constellation / landfall ability words — no individual CR entry).
Discriminating-test map
identical_states_equal_under_both_comparisonssame_board_different_resources_is_modulo_equal_but_strictly_unequalrevert_probe_projection_is_load_bearingloop_states_equalrejectsloop_states_equaland the discriminator collapsesextra_permanent_is_not_modulo_equaldifferent_tap_state_is_not_modulo_equalsnapshot_and_delta_measure_known_changessnapshotreads mana/life/counters;deltameasures a known monotone change exactlynet_progress_classificationis_net_progresstrue for +damage/−nothing, false for no-op and for net-negative lifeis_net_progressconsumed-axis ruleunbounded_components_names_growing_axesunbounded_componentsGate results
cargo fmt --all: clean./scripts/check-parser-combinators.sh: exit 0 (no parser files touched)./scripts/check-engine-authorities.sh upstream/main: exit 0cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings: cleancargo test -p engine: 13095 passed; the only failure is the known parallel-flakydelve_payment_skips_state_clone_per_graveyard_candidate(passes in isolation, unrelated to this additive module).data/engine-inventory.json: unchanged.