feat(engine): combo-detection PR-1 — analysis sim harness feeding ResourceVector - #4097
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
matthewevans
left a comment
There was a problem hiding this comment.
Blocking this one for a PR-1-specific false-positive in the event-fed axes, plus the inherited PR-0 ResourceVector blocker.
HIGH: ordinary turn/combat progression is counted as extra turns and extra combats
The new event feed maps every GameEvent::PhaseChanged { phase: BeginCombat } to combat_phases += 1 and every GameEvent::TurnStarted to extra_turns += 1 (crates/engine/src/analysis/sim.rs:90-99). LoopProbe::iteration_delta then splices those event-fed counts verbatim into the returned delta (sim.rs:211-220, sim.rs:246-254), and ResourceVector::is_net_progress treats both combat_phases and extra_turns as gained axes (crates/engine/src/analysis/resource.rs:310-321, resource.rs:349-360).
Those events are not evidence that an extra phase/turn was created. Natural phase entry also emits PhaseChanged { BeginCombat }, and the turn code emits the same TurnStarted event after both natural and extra turns: start_next_turn computes an is_extra_turn flag for replacement handling, but the emitted GameEvent::TurnStarted carries only player_id and turn_number (crates/engine/src/game/turns.rs:463-489, turns.rs:686-689; crates/engine/src/types/events.rs:115-123). Extra turns are created by pushing state.extra_turns in the ExtraTurn resolver (crates/engine/src/game/effects/extra_turn.rs:35-41), and extra combats are created as state.extra_phases entries (crates/engine/src/game/effects/additional_phase.rs:194-202, additional_phase.rs:222-237), not by the later ordinary-looking phase/turn events.
As written, a probe window that naturally crosses begin combat or the next turn reports net progress on CombatPhases/ExtraTurns even if no effect created an additional phase or turn. That is exactly the kind of false-positive loop certificate the combo detector cannot build on. Please feed these axes from the creation/queueing event or add an event field that distinguishes natural vs extra, and add negative tests for a natural BeginCombat and a natural next turn producing zero extra-combat/extra-turn progress.
This head also still contains the PR-0 ResourceVector commit that already has a Changes Requested review for erasing consumable/state-gating counters in loop_states_equal_modulo_resources, so PR-1 should not be approved until that foundation is corrected as well.
…ourceVector Adds the offline analysis simulation harness around `GameRunner::act` that consumes the PR-0 `ResourceVector` and FEEDS its event-fed axes (damage, tokens, draws, casts, landfall/combat/extra-turn counts, ETB/LTB/death/sac triggers, and proliferate) from the runner's `ActionResult` event stream — the half a single `GameState` snapshot cannot supply. - `analysis/sim.rs`: `accumulate_events` (event -> event-fed axis fold, event-driven so it covers a class of cards) and `LoopProbe` (drives `GameRunner::act`, snapshots state-readable axes at iteration boundaries, splices the per-iteration event tally as the event-fed half). - `analysis/mod.rs`: register `sim` and re-export `LoopProbe` / `accumulate_events`. Offline / zero gameplay change: the harness only observes the runner; no reducer/SBA/resolution edits. The detector hook is PR-2. Discriminating tests: per-axis event-fold coverage (each arm is its own revert probe), an end-to-end Lightning-Bolt cast driven through `LoopProbe::act` that feeds `damage_dealt`/`casts_this_step` from real `DamageDealt`/`SpellCast` events, a two-iteration boundary-isolation test, and a state-readable-vs-event- fed separation test. Assisted-by: ClaudeCode:claude-opus-4.8
The offline `analysis::sim` LoopProbe counted every natural
`PhaseChanged{BeginCombat}` into `combat_phases` and every natural
`TurnStarted` into `extra_turns`. Both events fire on ordinary turn and
combat progression, so any probe window crossing a natural begin-combat
or next turn reported false net loop progress — exactly the false-positive
loop certificate a CR 732.2a net-progress detector must never build on.
Re-route both axes to creation signals, per-axis:
- `extra_turns` stays event-fed but switches its source from the natural
`TurnStarted` event to the `EffectResolved{ExtraTurn}` creation event
(CR 500.7). One `Effect::ExtraTurn` resolve == one push == one event,
with no no-op early-return and no multi-push, so this is a clean 1:1
creation count.
- `combat_phases` moves out of the event feed entirely and becomes
state-readable in `ResourceVector::snapshot`: extra combats already
entered (`combat_phases_started_this_turn` minus the one natural combat
per CR 506.1) plus extra `BeginCombat` phases still queued in
`state.extra_phases` (CR 500.8). The two terms are disjoint (advance_phase
removes an extra phase before entering it), so a consumed extra combat is
retained by the entered term and a self-sustaining loop does not net to
zero. This sidesteps the `EffectResolved{AdditionalPhase}` event's three
defects (one event for N pushes, an event on no-op early returns per
CR 500.10a, and no phase-kind to combat-filter).
Offline / zero gameplay change: `snapshot` only reads existing `GameState`
fields; no reducer/SBA/event-emission edit. Honest coverage gap documented:
the scheduled `grant_extra_turn_after` turn-control path pushes onto
`state.extra_turns` without emitting `EffectResolved{ExtraTurn}`, so that
less-common class is not counted on this axis.
Discriminating tests: two REQUIRED negatives driven through the real
pipeline (natural BeginCombat -> 0 extra combats; natural next turn -> 0
extra turns, each asserting the natural event reached the probe so the old
arm would have fired), plus positive creation feeds, an Obeka-class
multi-push (N=3), and hostile fixtures for the no-op AdditionalPhase
(CR 500.10a), a non-combat extra phase, and create-then-consume retention.
Assisted-by: ClaudeCode:claude-opus-4.8
c9c62c9 to
065aca4
Compare
|
🤖 AI text below 🤖 Addressed the blocking review and rebased the stack. Rebased onto the latest Fix for the HIGH finding (ordinary turn/combat progression counted as extra turns/combats). Both axes are now fed from creation signals instead of natural-progression events, per-axis because the two are not symmetric:
Net effect: a probe window crossing a natural begin-combat or next turn now reports zero extra-combat/extra-turn progress. Negative tests (the reviewer's explicit ask), driven through the real pipeline:
Both were verified to fail when the old arms are restored. Also added positive creation feeds, an Obeka-class multi-push (N=3), and hostile fixtures for the no-op Offline / zero gameplay change: The PR-0 Gates (Tilt down, foreground): |
matthewevans
left a comment
There was a problem hiding this comment.
Prior blocker is addressed on the current head: natural PhaseChanged { BeginCombat } and natural TurnStarted no longer feed the extra-combat/extra-turn axes, and the new runner-level regressions exercise those natural events non-vacuously. The extra-turn axis is now tied to EffectResolved { ExtraTurn }, while extra combat is read from queued/entered ExtraPhase { BeginCombat } state with no-op/non-combat controls.
CI is green on the reviewed head and the repush guard held. VERDICT: approve
🤖 AI text below 🤖
Where this sits in the staged infinite-combo-detection effort
Goal of the overall effort: confirm a claimed deterministic loop and classify which resource goes unbounded (so the engine can shortcut a beneficial loop per CR 732.2a instead of only drawing a truly-repeating mandatory loop per CR 104.4b / CR 732.4). The work is staged PR-0..PR-8; the design docs live untracked in
.planning/combo-detection/(IMPLEMENTATION.md§5/§7/§8,FEASIBILITY-AND-PLAN.md).ResourceVector(the monotone resource axes a loop can pump) +loop_states_equal_modulo_resources(board/zones/tap identical, resources allowed to differ), reusing the existingloop_fingerprint/normalize_for_loop/loop_states_equalmachinery.GameRunner::actthat consumesResourceVectorand makes its measurement substrate real.loop_windowsite →LoopCertificate(PR-2),emit_resolution_haltclassification → live shortcut (PR-3), optional static ability-graph (PR-4),cargo combo-verifyCLI over the corpus (PR-5),∞unbounded-resource display (PR-6), loop shortcut + opponent response window (PR-7), AI coupling (PR-8).What PR-1 added
PR-0's
ResourceVector::snapshot(&GameState)deliberately fills only the state-readable axes (mana, life, library size, counters) and leaves the event-fed axes atDefault—damage_dealt,tokens_created,cards_drawn,casts_this_step,landfall_triggers,combat_phases,extra_turns,death_triggers,etb_triggers,ltb_triggers,sac_triggers, andgeneric_triggers. Those are events, not totals a singleGameStateretains.This PR adds
crates/engine/src/analysis/sim.rs:accumulate_events(&mut ResourceVector, &[GameEvent])— folds one action's game-event stream into the event-fed axes.LoopProbe<'r>— wraps aGameRunner, snapshots the state-readable axes at iteration boundaries, accumulates events across the actions of an iteration viaact, anditeration_delta()returns the per-iterationResourceVector(state-readable half = snapshot delta; event-fed half = the per-iteration event tally taken verbatim).analysis/mod.rsregisters thesimmodule and re-exportsLoopProbe/accumulate_events. Nopubvisibility bumps were needed (analysis is in-crate).data/engine-inventory.jsonis unchanged (no Effect/ability variants added).Offline / zero gameplay change. The harness only observes the runner — no reducer, SBA, or resolution edits. The detector hook itself is PR-2.
Event-feed wiring approach
The fold is event-driven, not card-driven, so each axis covers a class of cards rather than one card:
GameEventsourcedamage_dealt[player]DamageDealt { target: Player }+CombatDamageDealtToPlayertokens_createdTokenCreatedcards_drawnCardDrawn(per-card) +CardsDrawn { count }(batch; disjoint paths)casts_this_stepSpellCast(copies are not casts → not counted)combat_phasesPhaseChanged { BeginCombat }extra_turnsTurnStartedetb_triggers/landfall_triggersZoneChanged { to: Battlefield }(landfall iff theZoneChangeRecordcore types containLand)ltb_triggers/death_triggersZoneChanged { from: Battlefield }(death iffto: Graveyard)sac_triggersPermanentSacrificedgeneric_triggers[Proliferate]PlayerPerformedAction { Proliferate }State-readable axes are intentionally not routed through the event feed — they come from
ResourceVector::snapshotso the two halves never double-count.Discriminating-test map (+ revert-probe)
accumulate_events_feeds_every_axis— feeds the realGameEventvariant for every event-fed axis and asserts each populates. Each match arm is its own revert probe: delete an arm and that axis stays 0.accumulate_events_no_events_is_noop— empty stream ⇒ every event-fed axis atDefault(pins the floor for the end-to-end probes).accumulate_events_within_battlefield_is_not_etb_or_ltb/accumulate_events_non_proliferate_player_action_ignored— negative cases (no spurious ETB/LTB on a within-battlefield move; non-proliferate player actions ignored).loop_probe_feeds_damage_from_real_pipeline— end-to-end through the realapply()pipeline: casts Lightning Bolt at the opponent viaLoopProbe::act(CastSpell→SelectTargets→ resolve), then assertsiteration_deltafeedsdamage_dealt = 3andcasts_this_step = 1from the realDamageDealt/SpellCastevents (revert probe: those arms are the only feed for those axes).loop_probe_iteration_delta_isolates_each_iteration— two damage iterations; eachiteration_deltareports only its own 3 (boundary roll-forward; without it the 2nd would read 6).loop_probe_state_readable_axis_independent_of_event_feed— a poison counter set between boundaries surfaces from the snapshot with no event-feed arm involved (pins the state-readable vs event-fed division of labor).Gate results
cargo fmt --allclean ·check-parser-combinators.sh(with and withoutupstream/main) exit 0 ·check-engine-authorities.sh upstream/mainexit 0 ·cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warningsclean ·cargo test -p engine: 13101 passed; 2 failed — the two failures are the known parallel-flakydelve_payment_skips_state_clone_per_graveyard_candidateandtarget_selection_legal_actions_do_not_simulate_each_target, both confirmed green in isolation with--test-threads=1.data/engine-inventory.jsonunchanged. No new CR annotations are unverified againstdocs/MagicCompRules.txt.