Skip to content

fix(engine): infinity-badge/offer correctness + per-entry token occurrence index (combo-fb phases 3-4, chain 2) - #6839

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
lgray:combo-fb/phase-3-4
Aug 1, 2026
Merged

fix(engine): infinity-badge/offer correctness + per-entry token occurrence index (combo-fb phases 3-4, chain 2)#6839
matthewevans merged 3 commits into
phase-rs:mainfrom
lgray:combo-fb/phase-3-4

Conversation

@lgray

@lgray lgray commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

DRAFT until chain link 1 merges — this diff currently includes chain-1 commits; after chain 1 lands this branch rebases, the diff reduces to phases 3–4 only, and the PR flips to non-draft. Chain management is handled by the author.

Summary

Chain 2 of the combo-detector player-feedback epic (phases 3–4): a resource whose collapse is already scheduled renders no ∞ badge (CR 732.2c); infinite-combo shortcuts are offered for ETB-sourced life loops at full multiplayer (CR 732.2a); and every token battlefield entry gets its own CR 603.2c occurrence index, fixing same-turn token batches aliasing onto occurrence #0 (which swallowed the second batch's batched trigger — exactly the workload combo loops generate).

Files changed

  • crates/engine/src/analysis/resource.rs, crates/engine/src/game/derived_views.rs — ∞-badge/collapse scheduling
  • crates/engine/src/ai_support/candidates.rs, crates/engine/src/game/engine.rs, game/turns.rs — ETB-life-loop offer at full multiplayer
  • crates/engine/src/game/effects/{token,token_copy,incubate}.rs, game/engine_replacement.rs, game/restrictions.rs, crates/engine/src/types/game_state.rs — per-entry token occurrence index routed through the record_zone_change single authority
  • crates/engine/tests/fixtures/cr733/authority_matrix.json.gz, crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz — fixtures
  • crates/engine/tests/integration/{combo_infinite_pile,cr733_resolved_copy_token_creation,cr733_resolved_token_creation,kilo_live_offer_from_real_dump,loop_shortcut,loop_shortcut_mana_engine,token_zone_change_index,main}.rs — coverage
  • crates/engine/src/game/triggers.rs — inherited via the chain-1 prefix: a style(engine) commit (471484867) rewrapping the ability import list to rustfmt canonical form (one hunk, no semantic change), fixing the cargo fmt --all -- --check CI failure

Track

Developer

LLM

Model: claude-fable-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

CR references

CR 732.2c (shortcut ends the loop — no ∞ badge for a scheduled collapse), CR 732.2a (shortcut offers), CR 603.2c (one trigger per event occurrence — per-entry token occurrence index), CR 733 (resolved-copy token creation coverage).

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head. (CI-owned alternative stated below, per author-directed remote-CI reliance for chain submissions.)

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • Branch-tip battery at 04b6bb600 (contains this range, rebased on 73fd7f6de): cargo check --workspace — 0; cargo clippy --workspace --all-targets -- -D warnings — 0; cargo test -p phase-engine lib — 18124 passed / 0 failed; integration — 4304 passed / 0 failed; cargo test -p phase-ai — 1930 passed / 0 failed.

  • FORGE_TEST_FULL_DB=1 ordering_parity_sweep two-arm (merge-base vs tip, identical corpus) — unexplained=18 both arms, byte-identical lists, delta-0.

  • scripts/ai-perf-gate.sh two-arm — 30-row tables byte-identical, delta-0.

  • Phases 3–4 closed through the full /engine-implementer pipeline with review-impl to zero findings (lane journal; phase-close SHAs 19bb0f416 phase 3, 2c34a9759/36e9668d1 phase 4, pre-rebase).

Gate A

Deferred to the CI-owned parse-diff check for this submission (author-directed remote-CI reliance for the PR chain). head=61d6adcd1 base=02760f588. No parser//oracle* paths in this range — expected parse delta is zero.

Anchored on

  • crates/engine/src/game/restrictions.rs (record_zone_change) — the existing single zone-change authority; the token fix routes token births through it and deletes three co-located record_battlefield_entry calls that would double-count
  • crates/engine/src/game/engine_replacement.rs — the existing CR 603.2c batched-replay dedup seam (dedups on (definition_ref, turn_zone_change_index)) that the per-entry index makes correct

Final review-impl

Final review-impl PASS at each phase close (zero findings; lane journal), re-verified at branch tip 04b6bb600 by the full boundary battery above.

Claimed parse impact

None.

Scope Expansion

None. Note: the token occurrence-index commit (61d6adcd1) is a prerequisite engine fix with standalone value and the widest non-combo blast radius in the chain — it can be split into its own PR on request.

Validation Failures

None.

CI Failures

None. (Rebased onto upstream/main 02760f588 2026-07-31; chain rebased as a unit — phase-0-2 remains a strict prefix of phase-3-4. Conflicts: none — the one predicted overlap, crates/engine/tests/integration/main.rs, merged cleanly (additive mod lines on both sides); post-rebase that file is upstream's version plus exactly one added line, mod token_zone_change_index;, at its sorted position. git range-diff 73fd7f6de..22780493c 02760f588..61d6adcd1 reports 8/8 = on the pre-existing commits (patch-identical, zero content drift), plus one commit added after that rebase: 471484867 (the rustfmt rewrap answering review item 4).)


Chain & series context

This PR is chain link 2 — predecessor: chain 1 (linked in first comment); successor: chain 3 (phase 5, 5a–5d bounded fast-forward + pinned optional links) opens when phase 5d completes.

Predecessor series (combo-detector): #4119 · #4480 · #4493/#4534 · #4547 · #4603 · #5672 · #6238 · #6259.

Summary by CodeRabbit

  • Bug Fixes
    • Loop-collapse prompts now respect accepted iteration limits, including zero-iteration outcomes.
    • Scheduled finite materializations no longer display conflicting unbounded resource indicators or piles.
    • Token and incubate entries now correctly trigger battlefield-entry effects and maintain zone-change history.
    • Token replay preserves predefined abilities and entry-trigger behavior.
    • Life-generating loop collapses avoid duplicate life gains.
  • Tests
    • Expanded coverage for resource displays, loop boundaries, token replay, trigger handling, and collapse behavior.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds accepted loop-materialization bounds and scheduled-axis tracking, updates collapse prompts and derived views, routes ETB-sensitive life growth through replay, and centralizes token and incubator zone-change bookkeeping. It also adds replay and integration coverage for these paths.

Changes

Loop materialization and resource views

Layer / File(s) Summary
Materialization state and collapse-axis authority
crates/engine/src/types/game_state.rs
GameState stores accepted materialization counts, derives scheduled collapse axes, clears related metadata, and reuses shared counter-axis logic.
Bounded collapse prompts and derived views
crates/engine/src/game/engine.rs, crates/engine/src/game/turns.rs, crates/engine/src/ai_support/candidates.rs, crates/engine/src/game/derived_views.rs, crates/engine/tests/integration/{combo_infinite_pile,kilo_live_offer_from_real_dump,loop_shortcut,loop_shortcut_mana_engine}.rs
Accepted counts now bound later collapse prompts and AI actions. Scheduled finite axes are hidden from unbounded resource, pile, and counter projections while stored state remains available.
ETB-aware life-growth routing
crates/engine/src/analysis/resource.rs, crates/engine/src/game/engine.rs, crates/engine/tests/integration/combo_infinite_pile.rs
Functioning ETB triggers are detected through shared iteration. Token-producing life loops with such triggers use replay, with coverage for trigger ownership, lifelink, batching, and per-cycle indexing.

Token entry recording and replay

Layer / File(s) Summary
Canonical token and incubator entry recording
crates/engine/src/game/effects/{incubate,token}.rs, crates/engine/src/game/engine_replacement.rs, crates/engine/src/game/restrictions.rs, crates/engine/src/types/game_state.rs, crates/engine/tests/integration/{main,token_zone_change_index}.rs
Token and incubator entries use record_zone_change for indices and battlefield-entry ledgers. Deferred liminal entries emit post-actions after replacement resolution.
Resolved token replay fidelity
crates/engine/src/game/effects/{token,token_copy}.rs, crates/engine/tests/integration/{cr733_resolved_copy_token_creation,cr733_resolved_token_creation}.rs
Replay injects predefined token abilities before rebuilding entry snapshots. Tests verify copied-token stripping, journal replay, rules text, abilities, indices, and ledger state.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Controller
  participant GameState
  participant CollapsePrompt
  participant DerivedViews
  Controller->>GameState: accept Fixed(n) shortcut
  GameState->>CollapsePrompt: set collapse maximum to accepted bound
  GameState->>DerivedViews: expose scheduled collapse axes
  DerivedViews-->>Controller: hide scheduled unbounded projections
Loading
sequenceDiagram
  participant TokenEffect
  participant RecordZoneChange
  participant GameState
  participant TriggerPipeline
  TokenEffect->>RecordZoneChange: submit token entry snapshot
  RecordZoneChange->>GameState: assign zone-change index and record entry
  RecordZoneChange->>TriggerPipeline: emit ZoneChanged event
  TriggerPipeline-->>GameState: update ETB trigger state
Loading

Possibly related PRs

Suggested labels: quality

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main fixes: infinity-badge and offer correctness plus per-entry token occurrence indexing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@lgray

lgray commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Chain link 1 (phases 0–2, open for review now): #6838 — this draft's diff includes #6838's commits until it merges; after merge this branch rebases and the diff reduces to phases 3–4 only.

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 31, 2026
@lgray
lgray force-pushed the combo-fb/phase-3-4 branch from 2278049 to 7cf16a4 Compare July 31, 2026 15:28
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@lgray

lgray commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Rebased onto upstream/main 02760f588 as a chain unit (phase-0-2 stays a strict prefix of phase-3-4). New heads: #6838 201bdb6e4, #6839 7cf16a49c. Conflicts: none — the one predicted overlap (crates/engine/tests/integration/main.rs) merged cleanly; git range-diff reports 8/8 patch-identical commits.

@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.

Maintainer hold — current draft head 7cf16a49c9b23aee72cf15babad2d00c137587e8.

This chained PR explicitly remains draft until #6838 merges and rebases; it cannot be approved or enqueued on this head. I reviewed the current own arc and the full external-feedback surface: parse-diff is correctly no_changes, CodeRabbit skipped review because the PR is draft, and no review threads are open.

The required Rust suite is also failing/pending on the included predecessor. Re-open this for a fresh independent current-head review after #6838 is resolved, the branch rebases to its phases 3–4-only diff, the PR is marked ready, and required CI is clean.

@matthewevans matthewevans added the bug Bug fix label Jul 31, 2026
@lgray
lgray force-pushed the combo-fb/phase-3-4 branch from 7cf16a4 to 61d6adc Compare July 31, 2026 15:53
@lgray

lgray commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Understood on the maintainer hold — this stays draft until #6838 merges and this branch rebases to its phases 3–4-only diff, then goes ready for a fresh independent current-head review.

Head update only: the cargo fmt --all -- --check CI failure was fixed on the chain-1 prefix (471484867, style(engine) — a one-hunk import-list rewrap in crates/engine/src/game/triggers.rs, no semantic change), so this branch inherits it via the prefix rather than carrying its own fix. Commits 6–8 were replayed onto the corrected base: new head 61d6adcd1.

Chain invariant preserved — phase-0-2 (471484867) remains a strict prefix of phase-3-4 (61d6adcd1), and git range-diff reports all three replayed commits patch-identical (=), i.e. zero content drift from the rebase. cargo fmt --all -- --check exits 0 on both heads.

Full response to the review findings is on #6838.

@matthewevans matthewevans self-assigned this Jul 31, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold — fresh head 61d6adcd10eb4811883d94e24ca90df206d6ef10.

The new delta from the prior held head is only the inherited rustfmt import-list rewrap in crates/engine/src/game/triggers.rs; the phase 3–4 commits were replayed without content drift. I confirmed the current parse-diff report is no_changes and there are no open review threads, but this PR is still marked Draft and its stated predecessor #6838 remains open. The current-head Rust lint/tests/card-data run is also still in progress.

No approval or merge-queue action is appropriate yet. Please rebase to the promised phases-3–4-only range after #6838 merges, mark the PR ready for review, and wait for the fresh required checks; that new ready, green head needs an independent review before enqueue.

@matthewevans matthewevans removed their assignment Jul 31, 2026
@lgray
lgray force-pushed the combo-fb/phase-3-4 branch from 61d6adc to fe81c97 Compare August 1, 2026 14:06
@matthewevans matthewevans self-assigned this Aug 1, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold on current head fe81c9741c65ca37f0aeafca9e24f3ce622649db.

#6838 is formally changes-requested on head 95165b5f8247c6cbd18e8f9ff4d872ec7c69fdc5. This chained PR must remain draft and unreviewed—not approved—until #6838 lands.

Exact next step: after #6838 lands, rebase this branch to the promised phases-3–4-only diff, then present a fresh non-draft head with current CI for review.

@matthewevans matthewevans removed their assignment Aug 1, 2026
lgray added 3 commits August 1, 2026 15:27
…o infinity badge (CR 732.2c)

Accepting a loop shortcut schedules the growth to materialize in one batch at
the CR 500.5 boundary rather than replaying it per cycle. Between the accept
and that boundary the board does not yet hold the growth, so the wire kept
advertising an unbounded Life/Tokens/Counters axis beside a finite life total,
a finite token pile, and finite counters -- an internally inconsistent HUD.

Filter the PROJECTION, never the STORE: `scheduled_collapse_axes` becomes the
single authority for "which axes will collapse", exhaustive over
`PersistentAxisMaterialization` with no wildcard so a new variant breaks the
build rather than silently rendering a stale infinity. Its three consumers --
the badge rows, the token pile, and the counter pills -- share that one
derivation, so the surfaces can never disagree. The store keeps its marks,
because the boundary collapse and `refill_infinite_mana` both read it.

A `DriveSequence` is the exception the class rule has to carve out: its
`collapsed_axes` is the loop's whole `proposal.unbounded`, not a deferral, and
a `Mana(_)` among them is live NOW -- `refill_infinite_mana` re-tops the pool
off the store after every action. Hiding it would show no infinity beside a
pool that keeps refilling: the same inconsistency, inverted. Mana is today's
only already-materialized axis; the census of production readers is recorded
beside the filter, along with the condition a future axis must meet to join it.

CR 732.2c also bounds the boundary prompt: the shortcut is taken at the count
every player accepted, so the collapse may not exceed it. That bound is
recorded only where a stash exists to bound -- gated on a measured stash-length
delta, because a route that registers nothing would otherwise strand a bound
that nothing clears and silently cap a later, larger, fully-agreed shortcut.
Two accepts in one phase bind to the minimum, under-delivering rather than
materializing growth the table never agreed to.

Assisted-by: ClaudeCode:claude-opus-4.8
…s at full multiplayer (CR 732.2a)

The object-growth veto treated every life-growing loop as unbounded, so a
token-minting ETB lifegain engine (Soul Warden / Prosperous Innkeeper /
Bogwater Lumaret, and the lifelink-ETB shape) never reached the CR 732.2a
shortcut offer in a real multiplayer game.

Route on an axis-shaped conjunct instead of an effect-shaped one: a life loop
takes the ETB-sourced route only when it grows life, mints tokens, and the
board actually carries a functioning enters-the-battlefield trigger. Each
conjunct is independently load-bearing, so a mana-only loop with a board ETB
trigger stays on the batched Life route.

`board_has_functioning_etb_trigger` is shape-agnostic (any functioning ETB
trigger, not a named effect), and the CR 113.6 zone gate it shares with the
sibling firewall is extracted into `functioning_board_trigger_defs` so both
walk the board through one authority. The low3 board-trigger axis is a typed
`Low3BoardEtbTrigger` rather than a bool, per parameterize-don't-proliferate.

Verification (measured, this commit alone on a clean base):
  cargo check --workspace --all-targets   exit 0
  combo_infinite_pile::                   27 passed / 0 failed
  (27 = the bucket's 28 minus the combined-gate test, which belongs to the
   follow-up commit; measured in a throwaway worktree on the clean base, not
   inferred from the full-tree run)

Assisted-by: ClaudeCode:claude-opus-4.8
…rrence index

Token entries were emitted with `turn_zone_change_index` hardcoded to 0, so
every token entry in a turn aliased onto occurrence #0. The CR 603.2c batched
replay guard dedups on `(definition_ref, turn_zone_change_index)`, so a second
same-turn token batch collided with the first and its batched trigger fire was
swallowed.

Route token births through `restrictions::record_zone_change`, the single
authority that assigns the index and performs the CR 403.3 battlefield-entry
bookkeeping. The three co-located `record_battlefield_entry` calls are deleted
in the same change, since the recorder does it and a second call double-counts.

This exposed a live/replay desync in the CR 733 resolved-commands journal: the
live path now pushes a ledger record for a token birth, but
`apply_resolved_token_creation` did not, so a journaled token birth followed by
a same-turn zone change failed closed with `TurnRecordIndexMismatch`. The
replayer now reconstructs the entry record and records it through the same
authority, which also closes a pre-existing gap where replay skipped the
CR 403.3 bookkeeping entirely. Reconstruction rather than a new command field,
so the serialized journal surface is unchanged.

The same applier also skipped token ability injection. It now dispatches on the
body variant, mirroring the live seam: a Copy body gets `finalize_copied_token`
+ the predefined-only injector (CR 707.2 + CR 702.33a strip cast-only
characteristics off a token copy); a Spec body gets the catalog injector.

Known ceiling, documented at the seam: the live journal point precedes entry
counters, `attach_to`, and injection, so a replayed record can still differ from
live for counter-carrying and Role/Aura token births. Closing those requires
moving the live record point and is out of scope here.

Note: `apply_resolved_token_creation` has no production caller yet — this is
replay fidelity ahead of wiring, exercised by the cr733 suites.

Verification (measured):
  cargo test -p engine    21975 passed / 0 failed / 15 ignored
  revert-probe: deleting the body dispatch fails 2 tests; replacing it with a
  blanket injector fails exactly 1, so both arms are separately load-bearing.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray
lgray force-pushed the combo-fb/phase-3-4 branch from fe81c97 to 81a020f Compare August 1, 2026 20:44
@lgray
lgray marked this pull request as ready for review August 1, 2026 20:45
@lgray

lgray commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Ready for review, per your note: rebased onto merged main, promoted from draft, phases-3–4-only diff.

#6838 landed as squash ed0a8e55c. This branch is rebased --onto that commit, dropping the
eight now-squashed commits, so the diff is finally the promised scope:

  • 3 commits, 21 files (was 11 commits / 32 files while chained)
  • head 81a020fb3, base main @ ed0a8e55c, MERGEABLE

Binary fixture change, disclosedtests/fixtures/cr733/authority_matrix.json.gz moves
268 → 269 fields. Both #6842 and this branch added one field to the same census from a
common base, which is a true binary conflict; I resolved it as a union, not a side-pick:

added removed
#6842 (now in main) next_delayed_trigger_token 0
this branch pending_materialization_count 0

Verified after construction: fields length 269; both names present; del(.fields) hashes
identical to the pre-merge file (envelope untouched); the transplanted element byte-identical
to its source; every pre-existing element preserved (set difference: 0 dropped, exactly 1
added); placement alphabetical within its local run
(pending_mass_library_order_choicepending_materialization_countpending_miracle_offers).
Re-gzipped -9 -n so the archive is reproducible. The consuming test
cr733_authority_matrix_covers_the_fresh_write_census passes on this head.

Local verification on 81a020fb3: cargo check --workspace --all-targets ✓,
clippy --workspace --all-targets -D warnings ✓, full cargo test -p phase-engine
(22589 passed / 0 failed / 15 ignored) ✓, check-parser-combinators.sh ✓,
check-engine-authorities.sh ✓ — the last two scoped to ed0a8e55c..HEAD.
cargo fmt --all reports no drift.

CI is running on this head; I'll report here if anything comes back red.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/engine/src/game/effects/token.rs (1)

1701-1722: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The CR 403.3 entry record now depends on event emission, so pause routes lose it. Deleting the unconditional record_battlefield_entry from the liminal finalize tail moved the entry record inside push_committed_token_entry_events, which is gated on TokenEntryEventEmission::Emit. On the Suppress route the record is owed to handle_copy_target_choice, and that emit is skipped on four pause returns from finish_copy_target_choice_entry: the current_self_enter_replacement_choice pause, the replay_deferred_entry_events pause, the batch-delivery drain, and EnteringAuraAttachment::NeedsChoice. On those routes the token has no battlefield_entries_this_turn record and no zone_changes_this_turn slot.

  • crates/engine/src/game/effects/token.rs#L1701-L1722: record the zone change unconditionally in this tail, and let TokenEntryEventEmission gate only the ZoneChanged and TokenCreated pushes. Split the record step out of push_committed_token_entry_events so both modes reach it.
  • crates/engine/src/game/engine_replacement.rs#L1777-L1805: once the record is unconditional, keep paused_entry_emit for the emit only, and correct the comment claim that the abandonment is pre-existing — it is pre-existing for the BecomeCopy chain and the copy continuation, not for the entry record.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/effects/token.rs` around lines 1701 - 1722, The token
finalization tail in crates/engine/src/game/effects/token.rs:1701-1722 must
record the zone change unconditionally, while TokenEntryEventEmission gates only
the ZoneChanged and TokenCreated event pushes; split that record step out of
push_committed_token_entry_events so both emission modes reach it. In
crates/engine/src/game/engine_replacement.rs:1777-1805, retain paused_entry_emit
for event emission and update the comment to state abandonment is pre-existing
only for the BecomeCopy chain and copy continuation, not the entry record.
🧹 Nitpick comments (3)
crates/engine/tests/integration/token_zone_change_index.rs (2)

318-333: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add the empty-ledger precondition that the sibling twin already asserts.

This test pins exact indices ([0, 1] then [2]). mixed_group_sibling_then_token_each_fire_the_batched_trigger guards the same arithmetic with an explicit zone_changes_this_turn.len() == 0 assertion. Without it, any scenario change that puts a zone change on the ledger before the batch makes this fail on the index values rather than on the precondition.

♻️ Proposed precondition
     let life_start = life_of_p0(runner.state());
+    // The index arithmetic below is only legible if the per-turn ledger starts empty.
+    assert_eq!(
+        runner.state().zone_changes_this_turn.len(),
+        0,
+        "the CR 400.7 per-turn zone-change ledger starts empty"
+    );
 
     let tokens = mint_token_batch(runner.state_mut(), host, 2);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/token_zone_change_index.rs` around lines 318
- 333, Add an explicit assertion before minting the token batch in the relevant
test that runner.state().zone_changes_this_turn.len() == 0, matching
mixed_group_sibling_then_token_each_fire_the_batched_trigger. Keep the existing
life delta and exact token index assertions unchanged.

613-621: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Link the recorded follow-up to a tracked issue.

This comment records a measured, unfixed gap: no board ETB trigger fires on the copy-target-choice resume route, with batched: true and with batched: false. The comment is the only record of it. A future reader cannot tell whether the gap is still open.

Do you want me to open an issue that captures the measurement and reference it here?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/token_zone_change_index.rs` around lines 613
- 621, The comment documents an unfixed copy-target-choice resume gap without a
trackable reference. Create or identify a tracked issue containing the measured
behavior for both batched modes, then update the comment near the
copy-target-choice resume assertions to include that issue’s reference while
preserving the existing scope and details.
crates/engine/src/ai_support/candidates.rs (1)

3030-3057: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a max: 0 test case for the new clamp.

(*max).min(1) exists specifically to keep this candidate legal when max is 0 (a shortcut accepted at Fixed(0)), per the added comment. The existing pay_amount_loop_collapse_offers_only_default_one test only covers max: 1000. Add a sibling case with max: 0 asserting the candidate submits amount: 0, so a future edit to this clamp cannot silently regress the zero-bound scenario without a test failing.

✅ Suggested additional test
#[test]
fn pay_amount_loop_collapse_clamps_to_zero_when_max_is_zero() {
    let state = GameState {
        waiting_for: WaitingFor::PayAmountChoice {
            player: PlayerId(0),
            resource: PayableResource::LoopCollapse {
                axis: LoopCollapseAxis::Tokens,
            },
            min: 0,
            max: 0,
            accumulated: 0,
            source_id: crate::types::identifiers::ObjectId(0),
            pending_mana_ability: None,
        },
        ..GameState::new_two_player(42)
    };
    let pay_amounts: Vec<u32> = candidate_actions(&state)
        .iter()
        .filter_map(|a| match a.action {
            GameAction::SubmitPayAmount { amount } => Some(amount),
            _ => None,
        })
        .collect();
    assert_eq!(
        pay_amounts,
        vec![0],
        "a shortcut accepted at Fixed(0) must offer amount 0, not an out-of-range 1"
    );
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/ai_support/candidates.rs` around lines 3030 - 3057, Add a
sibling test next to pay_amount_loop_collapse_offers_only_default_one covering
WaitingFor::PayAmountChoice with LoopCollapse and max: 0, then collect
SubmitPayAmount candidates and assert the sole offered amount is 0. Keep the
test focused on preserving the (*max).min(1) zero-bound behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/engine/src/game/effects/token.rs`:
- Around line 1701-1722: The token finalization tail in
crates/engine/src/game/effects/token.rs:1701-1722 must record the zone change
unconditionally, while TokenEntryEventEmission gates only the ZoneChanged and
TokenCreated event pushes; split that record step out of
push_committed_token_entry_events so both emission modes reach it. In
crates/engine/src/game/engine_replacement.rs:1777-1805, retain paused_entry_emit
for event emission and update the comment to state abandonment is pre-existing
only for the BecomeCopy chain and copy continuation, not the entry record.

---

Nitpick comments:
In `@crates/engine/src/ai_support/candidates.rs`:
- Around line 3030-3057: Add a sibling test next to
pay_amount_loop_collapse_offers_only_default_one covering
WaitingFor::PayAmountChoice with LoopCollapse and max: 0, then collect
SubmitPayAmount candidates and assert the sole offered amount is 0. Keep the
test focused on preserving the (*max).min(1) zero-bound behavior.

In `@crates/engine/tests/integration/token_zone_change_index.rs`:
- Around line 318-333: Add an explicit assertion before minting the token batch
in the relevant test that runner.state().zone_changes_this_turn.len() == 0,
matching mixed_group_sibling_then_token_each_fire_the_batched_trigger. Keep the
existing life delta and exact token index assertions unchanged.
- Around line 613-621: The comment documents an unfixed copy-target-choice
resume gap without a trackable reference. Create or identify a tracked issue
containing the measured behavior for both batched modes, then update the comment
near the copy-target-choice resume assertions to include that issue’s reference
while preserving the existing scope and details.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 812e1d5d-b9bc-4371-b2c3-345295356507

📥 Commits

Reviewing files that changed from the base of the PR and between ed0a8e5 and 81a020f.

⛔ Files ignored due to path filters (2)
  • crates/engine/tests/fixtures/cr733/authority_matrix.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz is excluded by !**/*.gz
📒 Files selected for processing (19)
  • crates/engine/src/ai_support/candidates.rs
  • crates/engine/src/analysis/resource.rs
  • crates/engine/src/game/derived_views.rs
  • crates/engine/src/game/effects/incubate.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/effects/token_copy.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/engine_replacement.rs
  • crates/engine/src/game/restrictions.rs
  • crates/engine/src/game/turns.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/tests/integration/combo_infinite_pile.rs
  • crates/engine/tests/integration/cr733_resolved_copy_token_creation.rs
  • crates/engine/tests/integration/cr733_resolved_token_creation.rs
  • crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/tests/integration/loop_shortcut_mana_engine.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/token_zone_change_index.rs

@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.

Current-head review complete — awaiting the aggregate required check.

I reviewed 81a020fb36dfba0f736cfbc2332f475ef3af4a8a as the promised independent phases-3–4 range. Current parse evidence is no_changes; Rust lint, both test shards, card-data, frontend, WASM, Tauri, and security are green. The required aggregate Rust (fmt, clippy, test, coverage-gate) is still queued, so this is not ready for approval or enqueue yet.

I also traced the outside-diff token pause concern: the Suppress copy-entry path now stashes EmitCommittedCopyTokenEntry, and the pending-counter resolver calls push_committed_token_entry_events, which is the same recorder/emitter authority used by the unpaused tail. I found no remaining current-head blocker from that report. I will re-evaluate merge action once the aggregate check settles.

@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 on current head 81a020fb36dfba0f736cfbc2332f475ef3af4a8a.

Evidence: the independent phases-3–4 diff is clean/mergeable; current parse evidence is no_changes; all required checks, including the aggregate Rust gate, are successful. I also verified the paused Suppress copy-token route reaches the shared recorder/emitter through EmitCommittedCopyTokenEntry, so the external pause-path concern is covered by the current implementation. Approval only; this sweep does not enqueue the PR.

@matthewevans
matthewevans added this pull request to the merge queue Aug 1, 2026
@lgray

lgray commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

On the pause-path concern you cleared — I measured it and believe it is narrower than "covered". Your call on what to do about it; I have not touched the approved head.

You wrote that the paused Suppress route "reaches the shared recorder/emitter through
EmitCommittedCopyTokenEntry". That is true of one pause return, not of the route.

Exhaustive measurement in engine_replacement.rs::finish_copy_target_choice_entry
(body = lines 1939–2071). The string counter_pause_post_actions occurs exactly twice:

line occurrence
1944 the parameter declaration
1961 the only use — append_pending_counter_post_actions(state, counter_pause_post_actions), inside the apply_etb_counters branch

There are five return Ok(Some(..)) sites: 1962, 1978, 2003, 2019, 2067. Only 1962 is
preceded by that stash. On the other four the vector is a dropped local, so
EmitCommittedCopyTokenEntry is never queued and the deferred emit never runs.

Since push_committed_token_entry_events is what now performs the CR 403.3 record, those four
returns leave the token with no battlefield_entries_this_turn row and no
zone_changes_this_turn slot
— where main before this branch called
record_battlefield_entry unconditionally in the finalize tail. So this specific
abandonment is introduced by my change, not pre-existing. CodeRabbit's report was right and I
was wrong to characterise it as pre-existing in my own comment.

Reachability — stated honestly. Route 3 (replay_deferred_entry_events) pauses on
DistributeAmong, on modal/target selection, and on OrderTriggers when several ETB observers
fire on one entry. That last case is named by the engine's own comment at that site: "two
constellation triggers, or Wedding Announcement's token + Ocelot Pride's life-gain rider on a
copy entry."
A copy-target token entering into two ETB observers is ordinary play, so I read
this as live, not latent. Caveat I want on the record: that is a code-path reading, and I
have not demonstrated it end-to-end with a runnable game scenario.

A fix is implemented and seam-tested locally — not pushed. It follows CodeRabbit's shape:
split record_committed_token_entry out so the Suppress finalize tail records unconditionally,
leave TokenEntryEventEmission gating only the two event pushes, and give the two late-emit
sites an emit-only twin that reuses the recorded row so nothing double-counts. Three inline seam
tests pin it in both directions, with the revert-probe run:

  • fix removed ⇒ suppressed_liminal_entry_still_records_the_cr_403_3_row FAILS left: 0, right: 1
  • the same probe leaves suppress_does_not_emit_the_entry_pair and
    emitted_liminal_entry_records_exactly_one_row passing — isolating the flip to the record
    rather than the events, and confirming the Emit path was never implicated
  • effects::token::tests:: 95 passed / 0 failed with the fix in place

Your choice, both ready:

  1. Push it here — I can have it on this head within minutes; it would reset your approval and need a re-look.
  2. Merge as-is and take it as an immediate follow-up PR — the record is lost only on those four pause returns, so if you would rather not disturb an approved head, I will open the follow-up straight away.

I did not push, because you had already given a disposition on exactly this concern and it is
your call which way it lands.

Merged via the queue into phase-rs:main with commit 9169d8f Aug 1, 2026
14 checks passed
@lgray

lgray commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Merged before you picked a route, so per the offer above I took the second one: the CR 403.3
record fix is now #6851, opened against merged main.

It also carries two defects that an independent review found in my fix after I posted here —
worth knowing since they change what the fix does:

  1. Recording in the finalize tail put the snapshot before BecomeCopy resolved, so the ledger
    row described the token pre-copy (a 0/0 Shapeshifter rather than the copied creature). fix(engine): record a token battlefield entry even when its events are suppressed (CR 403.3) #6851
    keeps the recorder-assigned index and refreshes the row's content at emit time.
  2. Refreshing only zone_changes_this_turn left battlefield_entries_this_turn pre-copy, so the
    two ledgers disagreed about one entry — ZoneChangeCountThisTurn and
    BattlefieldEntriesThisTurn would answer differently for the same token. Both are now
    refreshed in lockstep through restrictions::battlefield_entry_record_for.

Each fix carries a revert-probe with exactly one test flipping. Full drift gate green on the
head: 22592 passed / 0 failed.

lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…date set

The bounded-`UntilLethal` gate from the previous commit changes what the
generator emits at the real F4 offer, and two §5 U6 rows pinned the pre-gate
answer. They are this PR's own rows (added in 59afd02, "pin the bounded-offer
behaviour it actually has"), so what they pinned was the behaviour the
maintainer's Medium 5 identifies as wrong; re-pinning them is the discriminating
test for that fix, not an accommodation to it.

U6 (i) `..._is_untillethal_and_decline_only` => `..._is_decline_only`.
Measured: `legal_actions` at the F4 offer is now `[DeclineShortcut]` alone. Both
declare candidates are excluded, each by a different conjunct — `UntilLethal` by
`!schema.is_bounded()` (new), `Fixed(max_iterations)` by
`schema.points.is_empty()` (F4 publishes one point) — so the row carries one
REVERT-PROBE per exclusion. A single probe would leave the other exclusion
holding the assertion up and report a false pass. Both were RUN:

  * drop `!schema.is_bounded()` => `[DeclareShortcut { UntilLethal, None },
    DeclineShortcut]` vs `[DeclineShortcut]` => FLIPPED, rc=101;
  * drop `schema.points.is_empty() &&` => `[DeclareShortcut { Fixed(35), None },
    DeclineShortcut]` vs `[DeclineShortcut]` => FLIPPED, rc=101.

Both restored byte-identical (`cmp`), and the F4 suite is 11/11 green with
both gates in place.

The row's third reach-guard claimed `predicted_winner == None` supplied
REACHABILITY for `phase_ai::policies::loop_shortcut`'s `(None, UntilLethal)`
reject arm. That claim is now FALSE — the generator can no longer put that pair
in front of the policy from a bounded offer — so it is restated as a measured
property of the board, with the policy's own row named as the arm's coverage.
Leaving it as written would have been a false claim in a test.

U6 (ii) `u6_the_ais_only_declare_candidate_is_refused_...` =>
`u6_no_declaration_the_generator_can_emit_opens_the_window_...`. The AI has no
declare candidate at this node any more, so the old name and its doc table row
("the AI's own candidate") overclaimed. Its four one-axis `apply()` drives are
unchanged and still measure the ENGINE guard — which is exactly the fact the
generator gate depends on, so they are the reason the gate is correct rather
than collateral. Its `for action in legal_actions(..)` loop now iterates a
single element and is therefore WEAK on its own; that is said in the comment,
and an `is_empty()` positive control is added so an empty candidate set cannot
satisfy it vacuously.

Also stales-out an upstream comment this PR broke: `candidates.rs:3040` (from
phase-rs#6839) said the `LoopShortcut` arm "only ever proposes `UntilLethal`", hence no
AI-declared shortcut could produce the `PayableResource::LoopCollapse` prompt.
This PR's own 7d51e35 added the `Fixed(max_iterations)` candidate, and only a
`Fixed` count routes through `materialize_fixed_shortcut` — the single path that
registers the stash `turns.rs:455` turns into that prompt (traced, not assumed).
The prompt is AI-reachable now; the comment says so.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…date set

The bounded-`UntilLethal` gate from the previous commit changes what the
generator emits at the real F4 offer, and two §5 U6 rows pinned the pre-gate
answer. They are this PR's own rows (added in 59afd02, "pin the bounded-offer
behaviour it actually has"), so what they pinned was the behaviour the
maintainer's Medium 5 identifies as wrong; re-pinning them is the discriminating
test for that fix, not an accommodation to it.

U6 (i) `..._is_untillethal_and_decline_only` => `..._is_decline_only`.
Measured: `legal_actions` at the F4 offer is now `[DeclineShortcut]` alone. Both
declare candidates are excluded, each by a different conjunct — `UntilLethal` by
`!schema.is_bounded()` (new), `Fixed(max_iterations)` by
`schema.points.is_empty()` (F4 publishes one point) — so the row carries one
REVERT-PROBE per exclusion. A single probe would leave the other exclusion
holding the assertion up and report a false pass. Both were RUN:

  * drop `!schema.is_bounded()` => `[DeclareShortcut { UntilLethal, None },
    DeclineShortcut]` vs `[DeclineShortcut]` => FLIPPED, rc=101;
  * drop `schema.points.is_empty() &&` => `[DeclareShortcut { Fixed(35), None },
    DeclineShortcut]` vs `[DeclineShortcut]` => FLIPPED, rc=101.

Both restored byte-identical (`cmp`), and the F4 suite is 11/11 green with
both gates in place.

The row's third reach-guard claimed `predicted_winner == None` supplied
REACHABILITY for `phase_ai::policies::loop_shortcut`'s `(None, UntilLethal)`
reject arm. That claim is now FALSE — the generator can no longer put that pair
in front of the policy from a bounded offer — so it is restated as a measured
property of the board, with the policy's own row named as the arm's coverage.
Leaving it as written would have been a false claim in a test.

U6 (ii) `u6_the_ais_only_declare_candidate_is_refused_...` =>
`u6_no_declaration_the_generator_can_emit_opens_the_window_...`. The AI has no
declare candidate at this node any more, so the old name and its doc table row
("the AI's own candidate") overclaimed. Its four one-axis `apply()` drives are
unchanged and still measure the ENGINE guard — which is exactly the fact the
generator gate depends on, so they are the reason the gate is correct rather
than collateral. Its `for action in legal_actions(..)` loop now iterates a
single element and is therefore WEAK on its own; that is said in the comment,
and an `is_empty()` positive control is added so an empty candidate set cannot
satisfy it vacuously.

Also stales-out an upstream comment this PR broke: `candidates.rs:3040` (from
phase-rs#6839) said the `LoopShortcut` arm "only ever proposes `UntilLethal`", hence no
AI-declared shortcut could produce the `PayableResource::LoopCollapse` prompt.
This PR's own 7d51e35 added the `Fixed(max_iterations)` candidate, and only a
`Fixed` count routes through `materialize_fixed_shortcut` — the single path that
registers the stash `turns.rs:455` turns into that prompt (traced, not assumed).
The prompt is AI-reachable now; the comment says so.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
…date set

The bounded-`UntilLethal` gate from the previous commit changes what the
generator emits at the real F4 offer, and two §5 U6 rows pinned the pre-gate
answer. They are this PR's own rows (added in 59afd02, "pin the bounded-offer
behaviour it actually has"), so what they pinned was the behaviour the
maintainer's Medium 5 identifies as wrong; re-pinning them is the discriminating
test for that fix, not an accommodation to it.

U6 (i) `..._is_untillethal_and_decline_only` => `..._is_decline_only`.
Measured: `legal_actions` at the F4 offer is now `[DeclineShortcut]` alone. Both
declare candidates are excluded, each by a different conjunct — `UntilLethal` by
`!schema.is_bounded()` (new), `Fixed(max_iterations)` by
`schema.points.is_empty()` (F4 publishes one point) — so the row carries one
REVERT-PROBE per exclusion. A single probe would leave the other exclusion
holding the assertion up and report a false pass. Both were RUN:

  * drop `!schema.is_bounded()` => `[DeclareShortcut { UntilLethal, None },
    DeclineShortcut]` vs `[DeclineShortcut]` => FLIPPED, rc=101;
  * drop `schema.points.is_empty() &&` => `[DeclareShortcut { Fixed(35), None },
    DeclineShortcut]` vs `[DeclineShortcut]` => FLIPPED, rc=101.

Both restored byte-identical (`cmp`), and the F4 suite is 11/11 green with
both gates in place.

The row's third reach-guard claimed `predicted_winner == None` supplied
REACHABILITY for `phase_ai::policies::loop_shortcut`'s `(None, UntilLethal)`
reject arm. That claim is now FALSE — the generator can no longer put that pair
in front of the policy from a bounded offer — so it is restated as a measured
property of the board, with the policy's own row named as the arm's coverage.
Leaving it as written would have been a false claim in a test.

U6 (ii) `u6_the_ais_only_declare_candidate_is_refused_...` =>
`u6_no_declaration_the_generator_can_emit_opens_the_window_...`. The AI has no
declare candidate at this node any more, so the old name and its doc table row
("the AI's own candidate") overclaimed. Its four one-axis `apply()` drives are
unchanged and still measure the ENGINE guard — which is exactly the fact the
generator gate depends on, so they are the reason the gate is correct rather
than collateral. Its `for action in legal_actions(..)` loop now iterates a
single element and is therefore WEAK on its own; that is said in the comment,
and an `is_empty()` positive control is added so an empty candidate set cannot
satisfy it vacuously.

Also stales-out an upstream comment this PR broke: `candidates.rs:3040` (from
phase-rs#6839) said the `LoopShortcut` arm "only ever proposes `UntilLethal`", hence no
AI-declared shortcut could produce the `PayableResource::LoopCollapse` prompt.
This PR's own 7d51e35 added the `Fixed(max_iterations)` candidate, and only a
`Fixed` count routes through `materialize_fixed_shortcut` — the single path that
registers the stash `turns.rs:455` turns into that prompt (traced, not assumed).
The prompt is AI-reachable now; the comment says so.

Assisted-by: ClaudeCode:claude-opus-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants