🤖 AI text below 🤖
Summary
A player_scope fan-out that pauses mid-way for a player choice loses the per-player counts of every seat that already completed. Each resumed continuation leg replaces last_effect_counts_by_player instead of extending it, so after the fan-out finishes the table holds only the last resumed seat's entry.
This is pre-existing behaviour, surfaced during review of the Windfall draw-aggregate fix and disclosed there rather than repaired, because the repair is resume-machinery scope and affects every count-producing fan-out — not just the three cards that PR fixes.
Measured behaviour
Four seats, player_scope: All, Effect::Discard { count: 1, target: ScopedPlayer, selection: Chosen }, hands 1/2/1/1 so seat 1 must choose and the fan-out pauses there:
waiting_for = DiscardChoice { player: PlayerId(1), .. }
table at pause = [(0, 1)]
table after the continuation = [(3, 1)] <-- seat 0's real count is gone
last_effect_amount = Some(1) <-- an accumulating table gives 4
Seat 0 genuinely discarded a card. After the continuation resumes, nothing records it. Note the remaining seats chain into a SINGLE continuation leg, so seat 2's publication is replaced as well before the fan-out ends — the table is overwritten more than once, not merely truncated.
Mechanism
install_previous_effect_counts_by_player's Some arm assigns the table outright:
state.last_effect_counts_by_player = counts_by_player;
split_player_scope_chain sets scoped.player_scope = None, so each resumed leg is a bare ability that publishes only its own single entry rather than re-entering the fan-out driver.
Both in crates/engine/src/game/effects/mod.rs.
Scope — this is not aggregate-specific
last_effect_amount is derived from the same table (counts_by_player.values().copied().sum()), so the scalar loses the same counts. The Sum class is affected identically, which is the measurement that establishes this as pre-existing rather than introduced by the aggregate work: the drain shape (LoseLife → GainLife { PreviousEffectAmount }, 38 of the 41 quantity-position corpus carriers) reads that same scalar.
Reachability
Reachable for the three cards the Windfall PR fixes (Windfall, Jace's Archivist, Whispering Madness). They take discard::resolve's forced whole-hand branch, which emits no DiscardChoice — but that branch still pauses when a discard replacement effect requires a choice, in crates/engine/src/game/effects/discard.rs, at a site that already documents a related gap:
// Known limitation: EffectResolved is not emitted when replacement choice interrupts forced-discard (same systemic gap as sacrifice).
Consequence for the Max class: the draw link's capture_clause_minimum_snapshot then freezes Max over a table containing only the last resumed discarder, so every player draws that one player's hand size instead of the greatest number discarded (CR 608.2h determines the count once, over the whole completed action).
Suggested repair
Make the per-clause table accumulate across continuation legs rather than be replaced — either by merging on the install path when resuming a leg of the same clause, or by deferring the install until the fan-out's final leg completes. Either way the fix must keep the CR 608.2c semantics of Some(empty) (a real zero-result producer that must replace a stale table) distinct from "a resumed leg contributing one more entry", which is the reason a naive merge is not obviously correct.
What is already fixed and NOT part of this issue
The adjacent off-by-one — the pausing seat being published as a zero contributor before it had answered its choice — was introduced and fixed in the Windfall PR (applied_domain_end = i, not i + 1), with a frozen discriminating test paused_fan_out_excludes_the_seat_that_has_not_answered_its_choice. Probe with i + 1 restored: left: [(0,1),(1,0)] / right: [(0,1)].
CR references
CR 608.2c (instructions followed in the order written; the carry-forward channel), CR 608.2h (an effect's value is determined only once), CR 608.2e (the fan-out is one action processed simultaneously).
🤖 AI text below 🤖
Summary
A
player_scopefan-out that pauses mid-way for a player choice loses the per-player counts of every seat that already completed. Each resumed continuation leg replaceslast_effect_counts_by_playerinstead of extending it, so after the fan-out finishes the table holds only the last resumed seat's entry.This is pre-existing behaviour, surfaced during review of the Windfall draw-aggregate fix and disclosed there rather than repaired, because the repair is resume-machinery scope and affects every count-producing fan-out — not just the three cards that PR fixes.
Measured behaviour
Four seats,
player_scope: All,Effect::Discard { count: 1, target: ScopedPlayer, selection: Chosen }, hands 1/2/1/1 so seat 1 must choose and the fan-out pauses there:Seat 0 genuinely discarded a card. After the continuation resumes, nothing records it. Note the remaining seats chain into a SINGLE continuation leg, so seat 2's publication is replaced as well before the fan-out ends — the table is overwritten more than once, not merely truncated.
Mechanism
install_previous_effect_counts_by_player'sSomearm assigns the table outright:state.last_effect_counts_by_player = counts_by_player;split_player_scope_chainsetsscoped.player_scope = None, so each resumed leg is a bare ability that publishes only its own single entry rather than re-entering the fan-out driver.Both in
crates/engine/src/game/effects/mod.rs.Scope — this is not aggregate-specific
last_effect_amountis derived from the same table (counts_by_player.values().copied().sum()), so the scalar loses the same counts. TheSumclass is affected identically, which is the measurement that establishes this as pre-existing rather than introduced by the aggregate work: the drain shape (LoseLife→GainLife { PreviousEffectAmount }, 38 of the 41 quantity-position corpus carriers) reads that same scalar.Reachability
Reachable for the three cards the Windfall PR fixes (Windfall, Jace's Archivist, Whispering Madness). They take
discard::resolve's forced whole-hand branch, which emits noDiscardChoice— but that branch still pauses when a discard replacement effect requires a choice, incrates/engine/src/game/effects/discard.rs, at a site that already documents a related gap:Consequence for the Max class: the draw link's
capture_clause_minimum_snapshotthen freezesMaxover a table containing only the last resumed discarder, so every player draws that one player's hand size instead of the greatest number discarded (CR 608.2h determines the count once, over the whole completed action).Suggested repair
Make the per-clause table accumulate across continuation legs rather than be replaced — either by merging on the install path when resuming a leg of the same clause, or by deferring the install until the fan-out's final leg completes. Either way the fix must keep the CR 608.2c semantics of
Some(empty)(a real zero-result producer that must replace a stale table) distinct from "a resumed leg contributing one more entry", which is the reason a naive merge is not obviously correct.What is already fixed and NOT part of this issue
The adjacent off-by-one — the pausing seat being published as a zero contributor before it had answered its choice — was introduced and fixed in the Windfall PR (
applied_domain_end = i, noti + 1), with a frozen discriminating testpaused_fan_out_excludes_the_seat_that_has_not_answered_its_choice. Probe withi + 1restored:left: [(0,1),(1,0)] / right: [(0,1)].CR references
CR 608.2c(instructions followed in the order written; the carry-forward channel),CR 608.2h(an effect's value is determined only once),CR 608.2e(the fan-out is one action processed simultaneously).