fix(engine): Slaughter the Strong — keep a total-power-capped subset, sacrifice the rest (#4380)#4600
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements support for total-power-capped keep effects (such as "Slaughter the Strong") where players choose any number of creatures they control with total power N or less and sacrifice the rest. This includes updates to the parser, game state, action types, AI candidate generation, and resolution logic, along with comprehensive integration tests. The code review feedback suggests refactoring step_total_power to group recursive arguments into a context struct for better readability, and optimizing the validation logic in handle_resolution_choice by using HashSet instead of Vec::contains to avoid quadratic complexity.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| pub(crate) fn step_total_power( | ||
| state: &mut GameState, | ||
| source_id: ObjectId, | ||
| source_controller: PlayerId, | ||
| chooser_scope: CategoryChooserScope, | ||
| players_remaining: &[PlayerId], | ||
| all_kept: Vec<ObjectId>, | ||
| choose_filter: &TargetFilter, | ||
| sacrifice_filter: &TargetFilter, | ||
| cap: i32, | ||
| scoped_players: &[PlayerId], | ||
| events: &mut Vec<GameEvent>, | ||
| ) -> Result<(), EffectError> { |
There was a problem hiding this comment.
This function has a large number of arguments, which is acknowledged by #[allow(clippy::too_many_arguments)]. To improve maintainability and readability, consider grouping the arguments that are passed through recursively into a context struct. This would be especially beneficial for arguments that don't change during the recursion, like source_id, source_controller, chooser_scope, choose_filter, sacrifice_filter, cap, and scoped_players.
| let mut chosen = Vec::new(); | ||
| for id in &kept { | ||
| if !eligible.contains(id) { | ||
| return Err(EngineError::InvalidAction(format!( | ||
| "Creature {id:?} is not eligible to keep" | ||
| ))); | ||
| } | ||
| if !chosen.contains(id) { | ||
| chosen.push(*id); | ||
| } | ||
| } |
There was a problem hiding this comment.
The validation logic here uses Vec::contains inside a loop for both eligible and chosen lists, which has quadratic complexity (O(N*M)). For better performance and cleaner code, you could convert eligible to a HashSet before the loop for O(1) lookups, and use another HashSet to collect unique kept items.
| let mut chosen = Vec::new(); | |
| for id in &kept { | |
| if !eligible.contains(id) { | |
| return Err(EngineError::InvalidAction(format!( | |
| "Creature {id:?} is not eligible to keep" | |
| ))); | |
| } | |
| if !chosen.contains(id) { | |
| chosen.push(*id); | |
| } | |
| } | |
| let eligible_set: std::collections::HashSet<_> = eligible.iter().copied().collect(); | |
| let mut chosen_set = std::collections::HashSet::with_capacity(kept.len()); | |
| for id in &kept { | |
| if !eligible_set.contains(id) { | |
| return Err(EngineError::InvalidAction(format!( | |
| "Creature {id:?} is not eligible to keep" | |
| ))); | |
| } | |
| chosen_set.insert(*id); | |
| } | |
| let chosen: Vec<_> = chosen_set.into_iter().collect(); |
Parse changes introduced by this PR · 2 card(s), 2 signature(s) (baseline: main
|
a9d77e6 to
f0e705b
Compare
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Preserve source-controller provenance when recomputing eligible creatures. Evidence: crates/engine/src/game/effects/choose_and_sacrifice_rest.rs:311 builds FilterContext::from_source(state, source_id), while this waiting state already stores source_controller and the existing CategoryChoice resume path uses FilterContext::from_source_with_controller(source_id, controller) before recomputing eligibility. Why it matters: if this parameterized total-power mode is used with any controller-relative choose_filter, a resumed/serialized choice or missing source object can silently evaluate the eligibility filter with no controller and prompt/sacrifice the wrong set. Suggested fix: use FilterContext::from_source_with_controller(source_id, source_controller) in step_total_power, mirroring advance_to_next_player.
f0e705b to
afeaefd
Compare
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Total-power choices auto-keep all creatures when the rules still allow choosing fewer. Evidence: crates/engine/src/game/effects/choose_and_sacrifice_rest.rs:318-323 auto-keeps every eligible creature when their total power is already within the cap, and the regression locks that behavior in at crates/engine/tests/integration/slaughter_the_strong_total_power_4380.rs:95-97; CR 107.1c says “any number” may be zero. Why it matters: a player resolving Slaughter the Strong may intentionally choose fewer creatures, including zero, to sacrifice creatures even if their board already totals 4 power or less. Suggested fix: only auto-skip truly empty eligible sets; otherwise prompt for the subset, with UI/AI free to default to keeping all.
[MED] The new waiting state is missing from the resolution-continuation pause guard. Evidence: crates/engine/src/game/effects/mod.rs:1669-1732 lists the WaitingFor states that pause a resolving ability before its sub-ability/tail runs, but omits WaitingFor::KeepWithinTotalPowerChoice; the continuation install check at crates/engine/src/game/effects/mod.rs:6743-6753 depends on that helper. Why it matters: if this parameterized ChooseAndSacrificeRest mode is followed by any sub-ability/tail, that tail can run before the player answers the keep-within-power prompt. Suggested fix: add KeepWithinTotalPowerChoice beside CategoryChoice in waits_for_resolution_choice and add a chained-effect regression.
… sacrifice the rest phase-rs#4380: "Each player chooses any number of creatures they control with total power 4 or less, then sacrifices all other creatures they control." Previously the keep-constraint was dropped and every other creature was sacrificed. Parser: add a total_power_cap selection mode to Effect::ChooseAndSacrificeRest. "any number of creatures they control with total power N or less" parses to the capped keep mode, and the "sacrifices all other creatures they control" clause is absorbed by the choose-and-sacrifice followup (extended to a creature domain). Category cards (Cataclysm, Tragic Arrogance) are unchanged. Runtime: a new WaitingFor::KeepWithinTotalPowerChoice + GameAction::ChooseKept- Creatures drive a per-player (APNAP) subset choice validated against the cap; keeping all eligible creatures auto-resolves when already within the cap, and the unchosen are sacrificed via the shared sweep. AI candidate generation offers the empty keep and a greedy max-keep within the cap. Adds a runtime regression (caster prompted; over-cap keep rejected; the kept subset survives, the rest are sacrificed; a within-cap player auto-keeps).
afeaefd to
ca992b0
Compare
|
Backend re-reviewed current head I am not approving or enqueueing from the sweep because this PR still has frontend/client UI surface. Leaving that acceptance to Matt. |
Replace the hard-coded title/subtitle in KeepWithinTotalPowerChoiceModal with t() lookups and add the keepWithinTotalPower.title/subtitle entries (subtitle interpolates the power cap) to all seven supported locale catalogs.
|
@nickmopen please attach screenshots for all PRs that contain frontend changes |
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Manabrew snapshots do not mark the new keep-within-total-power eligible creatures as choosable. Evidence: crates/manabrew-compat/src/lib.rs:1326 handles the existing CategoryChoice sibling, but KeepWithinTotalPowerChoice falls through to _ => HashSet::new() at crates/manabrew-compat/src/lib.rs:1362; build_game_view consumes that set for card is_choosable at crates/manabrew-compat/src/lib.rs:433. Why it matters: the new player-facing prompt reaches Manabrew with no selectable/choosable eligible creatures, so Slaughter the Strong's new choice surface is incomplete in that adapter. Suggested fix: add a WaitingFor::KeepWithinTotalPowerChoice { player, eligible, .. } if *player == viewer => eligible.iter().copied().collect() arm and cover it in choosable_objects_includes_resolution_choice_siblings.
… choosable choosable_objects fell through to the empty default for the new KeepWithinTotalPowerChoice prompt, so build_game_view reported no choosable creatures for Slaughter the Strong's keep choice in the Manabrew adapter. Add an arm returning the viewer's eligible set (mirroring CategoryChoice) and cover it in choosable_objects_includes_resolution_choice_siblings.
matthewevans
left a comment
There was a problem hiding this comment.
[MED] Add the Manabrew prompt/action path for KeepWithinTotalPowerChoice.
The latest head fixes the narrow choosable-object regression, but Manabrew still cannot actually present or answer the new Slaughter the Strong choice. Evidence: build_prompt in crates/manabrew-compat/src/lib.rs has no WaitingFor::KeepWithinTotalPowerChoice arm, so this state still falls through to UnsupportedPrompt; the PR only adds KeepWithinTotalPowerChoice to the choosable_objects highlighting helper. That means a Manabrew client reaching this prompt can see eligible cards marked choosable in the snapshot, but prompt construction fails before the player can submit which creatures to keep. Please add a prompt/action path for KeepWithinTotalPowerChoice, including the cap/eligible object ids and a way to submit arbitrary ChooseKeptCreatures subsets rather than relying on the unsupported fallback.
…path choosable_objects highlighted the eligible creatures, but build_prompt had no KeepWithinTotalPowerChoice arm, so the state fell through to UnsupportedPrompt and a Manabrew client could never present or answer Slaughter the Strong's keep choice. Emit a chooseKeepWithinTotalPower prompt carrying the total-power cap and eligible card ids, and add a PlayerAction::KeepCreaturesDecision that submits an arbitrary kept subset directly as GameAction::ChooseKeptCreatures (the engine validates eligibility and the cap on apply). Adds a prompt+action round-trip test.
|
Backend/Manabrew re-review on current head I am not approving/enqueueing from the sweep because this PR still includes frontend/UI changes, so final frontend acceptance is deferred for maintainer handling. |
|
@matthewevans sure — here are the frontend screenshots for this PR. The frontend change is a new interactive choice modal, "Choose Creatures to Keep" ( 1. Initial state — nothing selected yet ( 2. Valid selection — Grizzly Bears (2) + Llanowar Elves (1) = 3. Over the cap — selecting Colossal Dreadmaw (6) pushes it to |
|
Ok, I really like this PR. One thing I've been trying to get away from (when possible) is showing modals/dialogs when the battlefield itself is better suited for whatever the selection is. I'd ideally like to see this where the player can select permanents directly on the battlefield with a total power (or whatever metric it is) indicator that the user sees as they select creatures. We should have precedence for this with WaitingFor::Sacrifice. |
|
@matthewevans makes sense — agreed that on-battlefield selection is the better UX here. I'll rework it to drop the modal and select directly on the battlefield, following the Concretely, the plan:
One small note: the existing board selections are count/range-based ( Does that approach sound right to you? If so I'll push the change. |
Yes this sounds great if we're leveraging existing patterns. Thank you! |
…not a modal (phase-rs#4380) Per review feedback on phase-rs#4600, replace KeepWithinTotalPowerChoiceModal with direct on-battlefield selection, reusing the existing WaitingFor::Sacrifice board-choice path (gameStateView BoardChoiceView). Adds a `keep` board-choice intent and a `totalPowerAtMost` selection variant (capped by combined power) with a live total/cap indicator in the targeting banner; confirm is blocked while the selection exceeds the cap. The engine contract is unchanged — the KeepWithinTotalPowerChoice WaitingFor still carries `eligible` + `cap`, and the ChooseKeptCreatures dispatch is identical; only the rendering moves from modal to board selection. Removes the now-unused modal component and its i18n keys; adds boardChoice keep/totalPowerAtMost strings and the keep badge across all locales.
matthewevans
left a comment
There was a problem hiding this comment.
[MED] totalPowerAtMost uses positive-clamped power in the client, rejecting legal Slaughter keep sets with negative-power creatures. Evidence: client/src/viewmodel/gameStateView.ts:486 sums Math.max(obj?.power ?? 0, 0), while the engine's Slaughter total uses raw power via obj.power.unwrap_or(0) at crates/engine/src/game/effects/choose_and_sacrifice_rest.rs:265. Why it matters: the engine allows a 5-power creature plus a -1-power creature under cap 4, but the UI would count that as 5 and disable confirm. Suggested fix: use raw power for totalPowerAtMost while preserving positive-only contribution for Crew/Saddle-style totalPowerAtLeast.
[MED] Collapsed grouped permanents do not get the keep-within-total-power picker. Evidence: client/src/components/board/GroupedPermanent.tsx:40 lists waiting states that can open the collapsed group picker, but it omits KeepWithinTotalPowerChoice; GroupedPermanent.tsx:105 exits before getBoardChoiceView can expose the group choices. Why it matters: collapsed identical eligible creatures cannot open the quantity picker for Slaughter, so the battlefield selection UI can only toggle the representative object. Suggested fix: include KeepWithinTotalPowerChoice in waitingForPlayer and cover the collapsed grouped keep-choice path.
…hase-rs#4380) Address two review issues on the Slaughter the Strong battlefield keep flow: - boardChoiceSelectedPower clamped every creature's power to >= 0, but the engine's CR 208.3 total sums raw power. A 5/-1 pair is legal under a cap of 4, yet the clamped sum (6) disabled confirm. Use raw power for the "keep" totalPowerAtMost intent while preserving positive-only contribution for Crew/Saddle totalPowerAtLeast. - waitingForPlayer omitted KeepWithinTotalPowerChoice, so collapsed groups of identical eligible creatures never opened the board-choice picker and could only toggle the representative object. Add the case so the collapsed grouped keep-choice path reaches getBoardChoiceView. Add a gameStateView test covering the raw-power keep total.
|
Backend/current-head re-review on I am leaving frontend acceptance deferred for Matt rather than approving/enqueueing from the sweep. |
…tal test The KeepWithinTotalPowerChoice WaitingFor data requires remaining_players, all_kept, and scoped_players; add them so the tsc -b type-check (which includes test files) passes.
|
Frontend screenshots for the reworked, on-battlefield keep selection (per the request to drop the modal and select directly on the battlefield with a live total-power indicator, following the The eligible creatures are selectable in place on the battlefield (each shows a KEEP badge — no modal), with a running 1. Initial — nothing selected yet, "Keep: power 0 / 4 or less": 2. Valid selection — Grizzly Bears (2) + Llanowar Elves (1) = 3/4, within the cap, Confirm enabled: 3. Over the cap — adding Colossal Dreadmaw (6) → 9/4, Confirm disabled: |
|
@nickmopen this looks good! In the second image, when you only have 2 creatures selected, I can't see anything indicating that they are selected and toggle-able? We need something to indicate what the current selection is. |
Selected multi-select board-choice permanents (Slaughter the Strong's keep set, and every other multi-select board choice) were distinguished from merely-eligible ones only by a subtle glow-intensity difference on the same-colored ring, with the intent badge shown identically on both. Give selected cards a checkmark on the badge plus an inset fill glow, and dim the badge on eligible-but-unselected cards, so the current selection is unambiguous and reads as a toggle.
|
Good catch — the selected vs. eligible-but-unselected states were only differing by a subtle glow intensity on the same ring, which was easy to miss (especially on art-less cards). Updated so the current selection is unambiguous and reads as a toggle: selected creatures now get a ✓ on the badge plus a brighter, filled (inset) glow, while eligible-but-unselected creatures show the same badge dimmed. This applies to every multi-select board choice, not just Slaughter. Here, only Grizzly Bears + Llanowar Elves are selected (3/4) — note the two And over the cap (3 selected → 9/4, Confirm disabled): |








Summary
Fixes #4380. "Each player chooses any number of creatures they control with total power 4 or less, then sacrifices all other creatures they control." Previously the keep-constraint was dropped and every other creature was sacrificed (the reported "it just kills all creatures").
Parser
total_power_capselection mode onEffect::ChooseAndSacrificeRest. "any number of creatures they control with total power N or less" parses to the capped keep mode, and "sacrifices all other creatures they control" is absorbed by the choose-and-sacrifice followup (extended with a creature domain).Runtime
WaitingFor::KeepWithinTotalPowerChoice+GameAction::ChooseKeptCreaturesdrive a per-player (APNAP) subset choice, validated so the kept set's combined power does not exceed the cap.sacrifice_unchosen), preserving co-departing leaves-the-battlefield observers.Tests
Runtime regression: the caster is prompted; an over-cap kept subset is rejected; the kept subset survives while the rest are sacrificed; a player already within the cap auto-keeps.
Verification
cargo fmt,clippy -p engine --lib, and the parser-combinator gate are clean. Full engine lib suite (14139 passed, 0 failed) plus the new integration test pass.