Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
492df5b
fix(engine): draw the greatest single player's discard, not the cross…
lgray Aug 16, 2026
8dd4403
test(engine): make the Windfall aggregate tests discriminating
lgray Aug 16, 2026
8c4a45d
fix(engine): freeze a draw clause's count once, per CR 608.2h
lgray Aug 16, 2026
2df1514
fix(engine): count zero-contributors in the previous-effect table
lgray Aug 16, 2026
b5d2e79
test(engine): cover the zero-fill's production wire, and correct its …
lgray Aug 16, 2026
7e9f950
fix(engine): exclude the paused seat from the zero-fill's domain
lgray Aug 16, 2026
3b89667
docs(engine): correct a comment that cited the pre-fix numbers as mea…
lgray Aug 16, 2026
c77dbf6
feat(engine): carry a paused discard batch across a replacement choice
lgray Aug 17, 2026
8fbaa5c
fix(engine): finish a discard batch that paused, and count what it di…
lgray Aug 17, 2026
07f55e9
test(engine): drive the paused discard through the real cast pipeline
lgray Aug 17, 2026
a354c1e
docs(ai): strike a CR tag from a detection heuristic that implements …
lgray Aug 17, 2026
137a405
docs(engine): say what CR 608.2i's exception actually exempts
lgray Aug 17, 2026
8cf8619
fix(engine): let the authority publish the replacement chooser, and r…
lgray Aug 17, 2026
48c73ed
test(engine): make three pins discriminate the thing their messages c…
lgray Aug 17, 2026
7308889
fix(engine): widen a census window that guarded a line and a half, an…
lgray Aug 17, 2026
126fc9a
test(engine): pin two guards that nothing would have caught, and rout…
lgray Aug 17, 2026
b19773f
merge: port PR-7494 across current main
matthewevans Aug 17, 2026
c504e2c
fix(PR-7494): persist paused clause snapshot
matthewevans Aug 17, 2026
a803988
test(PR-7494): target paused snapshot persistence
matthewevans Aug 17, 2026
7a35e6b
fix(PR-7494): persist Balance snapshot at discard choice
matthewevans Aug 17, 2026
64833fa
fix(PR-7494): bind a paused discard to its parked object incarnation
lgray Aug 17, 2026
52fe5d0
chore(PR-7494): port census across main
matthewevans Aug 17, 2026
a9d9ec8
fix(PR-7494): correct replacement rule annotations
matthewevans Aug 17, 2026
e7922fa
fix(PR-7494): repair the census pin list and finish the CR 616.1 reti…
lgray Aug 17, 2026
c980201
test(PR-7494): annotate Balance snapshot rule
matthewevans Aug 17, 2026
87dc1d5
Merge branch 'main' into fix/windfall-draw-aggregate
lgray Aug 17, 2026
defdbe3
test(PR-7494): use typed Leng fixture assertion
matthewevans Aug 17, 2026
0cc0d45
fix(engine): resume replacement-paused discard lists
matthewevans Aug 18, 2026
f50f6d5
fix(PR-7494): reject duplicate discard selections
matthewevans Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/engine/src/database/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6009,6 +6009,7 @@ fn build_extort_trigger() -> TriggerDefinition {
amount: QuantityExpr::Ref {
qty: QuantityRef::PreviousEffectAmount {
channel: crate::types::ability::DamageChannel::Total,
aggregate: AggregateFunction::Sum,
},
},
player: TargetFilter::Controller,
Expand Down Expand Up @@ -15058,6 +15059,7 @@ mod extort_synthesis_tests {
amount: QuantityExpr::Ref {
qty: QuantityRef::PreviousEffectAmount {
channel: DamageChannel::Total,
aggregate: AggregateFunction::Sum,
},
},
player: TargetFilter::Controller,
Expand Down
16 changes: 15 additions & 1 deletion crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2178,7 +2178,21 @@ fn scan_quantity_ref(x: &QuantityRef, mode: ScanMode) -> Axes {
},
},
QuantityRef::ExiledFromHandThisResolution => Axes::NONE,
QuantityRef::PreviousEffectAmount { .. } => Axes::NONE,
// CR 608.2c + CR 608.2i: every channel and every aggregate reads
// resolution-local state β€” `last_effect_amount` /
// `last_effect_excess_amount` / `last_effect_counts_by_player` /
// `clause_minimum_snapshot`, the last read FIRST (`game/quantity.rs`,
// the `PreviousEffectAmount` arm) as the CR 608.2h frozen value. All are
// cleared at depth-0 chain entry (`resolve_ability_chain`); `apply()`
// additionally clears `last_effect_count` and the per-player table at
// every player action. None is a triggering-event characteristic
// (event), a board-scoped mutable aggregate a sibling copy could mutate
// (sibling), or a player-level per-turn projected resource (projected).
// Destructured without `..` so a future field forces re-classification.
QuantityRef::PreviousEffectAmount {
channel: _,
aggregate: _,
} => Axes::NONE,
QuantityRef::PreviousEffectCount => Axes::NONE,
QuantityRef::LifeLostThisTurn { player } => {
let mut acc = Axes {
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/casting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7064,6 +7064,7 @@ fn x_spell_doubled_lose_life_drains_opponents_and_gains_controller() {
amount: QuantityExpr::Ref {
qty: QuantityRef::PreviousEffectAmount {
channel: crate::types::ability::DamageChannel::Total,
aggregate: AggregateFunction::Sum,
},
},
player: TargetFilter::Controller,
Expand Down
71 changes: 70 additions & 1 deletion crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1588,7 +1588,28 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String {
format!("# of counter kinds among {}", fmt_target(filter))
}
QuantityRef::VoteCount { choice_index } => format!("# of votes for choice {choice_index}"),
QuantityRef::PreviousEffectAmount { .. } => "amount from preceding effect".into(),
QuantityRef::PreviousEffectAmount { channel, aggregate } => match (channel, aggregate) {
// Byte-identical to the pre-change string, so no existing card's
// coverage signature moves. Must stay FIRST: the Excess-channel
// corpus cards are all `Sum` and must keep hitting this arm.
(_, AggregateFunction::Sum) => "amount from preceding effect".into(),
// CR 120.10: excess damage is "equal to the difference" beyond lethal β€”
// one amount per damaged permanent, never a per-player tally. Naming a
// "single player's" extremum over it would describe a reduction that
// never happened. (The per-player table the Total channel publishes is
// an engine structure; no CR governs its shape, so none is cited for it.)
// No parser path builds that pair today; the arm exists so the renderer
// stays honest if one ever does.
(crate::types::ability::DamageChannel::Total, AggregateFunction::Max) => {
"greatest single player's amount from preceding effect".into()
}
(crate::types::ability::DamageChannel::Total, AggregateFunction::Min) => {
"least single player's amount from preceding effect".into()
}
(crate::types::ability::DamageChannel::Excess, _) => {
"excess amount from preceding effect".into()
}
},
QuantityRef::PreviousEffectCount => "count from preceding effect".into(),
QuantityRef::TrackedSetSize => "cards moved".into(),
QuantityRef::FilteredTrackedSetSize { filter, .. } => {
Expand Down Expand Up @@ -15970,4 +15991,52 @@ mod tests {
"CantHaveKeyword(Flying) should be covered by is_data_carrying_static()"
);
}
/// The `fmt_quantity_ref` `PreviousEffectAmount` arms are ORDER-DEPENDENT:
/// the `(_, Sum)` arm must stay first so every Excess-channel corpus card
/// (all of which are `Sum`) keeps rendering the pre-change string. Nothing
/// enforced that ordering β€” reordering the arms would silently move the
/// coverage signature of every Excess card, reddening CI's coverage check
/// with no indication of the cause. rustc emits NO `unreachable pattern`
/// warning for the reorder, so the compiler will not catch it either. These
/// six assertions -- one per channel/aggregate pair -- are that guard.
#[test]
fn previous_effect_amount_renders_every_channel_aggregate_pair() {
use crate::types::ability::{AggregateFunction, DamageChannel};
let render = |channel, aggregate| {
fmt_quantity_ref(&QuantityRef::PreviousEffectAmount { channel, aggregate })
};

// Order-dependent: `(_, Sum)` is matched before the Excess catch-all, so
// the Excess+Sum pair renders the SUM string, not the excess one.
assert_eq!(
render(DamageChannel::Total, AggregateFunction::Sum),
"amount from preceding effect"
);
assert_eq!(
render(DamageChannel::Excess, AggregateFunction::Sum),
"amount from preceding effect",
"the (_, Sum) arm must stay FIRST: Excess+Sum is the shape the corpus \
actually holds, and it must keep the pre-change signature"
);
assert_eq!(
render(DamageChannel::Total, AggregateFunction::Max),
"greatest single player's amount from preceding effect"
);
assert_eq!(
render(DamageChannel::Total, AggregateFunction::Min),
"least single player's amount from preceding effect"
);
assert_eq!(
render(DamageChannel::Excess, AggregateFunction::Max),
"excess amount from preceding effect"
);
// The pair space is 2 channels x 3 aggregates = 6, which is more than the
// four match arms; `(Excess, Min)` routes through the same catch-all as
// `(Excess, Max)` and is asserted so the name's claim of completeness is
// literally true rather than true-of-the-arms.
assert_eq!(
render(DamageChannel::Excess, AggregateFunction::Min),
"excess amount from preceding effect"
);
}
}
Loading
Loading