Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.
Caveat: Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.
clear
ralph-loop:ralph-loop
Args: "Read mtg-rs/ROADMAP.md, and identify work that is outstanding to bring our rust engine into parity with Java. Do not implement any new sets, but add engine features that area missing. Our goal is to complete the fully functional implementation of ECL by implementing engine capabilities and updating cards to use them. Work one task at a time until we are done. Read the corresponding java source to understand how to shape the implementation. Update the ROADMAP and leave a note as you progress. Add tests for each engine feature you add. Commit changes after each item. Then get another ROADMAP item until the engine is at parity with the Java engine." --completion-promise "DONE" --max-iterations 90
Execute the setup script to initialize the Ralph loop:
🔄 Ralph loop activated in this session!
Iteration: 1
Max iterations: 90
Completion promise: DONE (ONLY output when TRUE - do not lie!)
The stop hook is now active. When you try to exit, the SAME PROMPT will be
fed back to you. You'll see your previous work in files, creating a
self-referential loop where you iteratively improve on the same task.
To monitor: head -10 .claude/ralph-loop.local.md
⚠️ WARNING: This loop cannot be stopped manually! It will run infinitely
unless you set --max-iterations or --completion-promise.
🔄
Read mtg-rs/ROADMAP.md, and identify work that is outstanding to bring our rust engine into parity with Java. Do not implement any new sets, but add engine features that area missing. Our goal is to complete the fully functional implementation of ECL by implementing engine capabilities and updating cards to use them. Work one task at a time until we are done. Read the corresponding java source to understand how to shape the implementation. Update the ROADMAP and leave a note as you progress. Add tests for each engine feature you add. Commit changes after each item. Then get another ROADMAP item until the engine is at parity with the Java engine.
═══════════════════════════════════════════════════════════
CRITICAL - Ralph Loop Completion Promise
═══════════════════════════════════════════════════════════
To complete this loop, output this EXACT text:
<promise>DONE</promise>
STRICT REQUIREMENTS (DO NOT VIOLATE):
✓ Use <promise> XML tags EXACTLY as shown above
✓ The statement MUST be completely and unequivocally TRUE
✓ Do NOT output false statements to exit the loop
✓ Do NOT lie even if you think you should exit
IMPORTANT - Do not circumvent the loop:
Even if you believe you're stuck, the task is impossible,
or you've been running too long - you MUST NOT output a
false promise statement. The loop is designed to continue
until the promise is GENUINELY TRUE. Trust the process.
If the loop should stop, the promise statement will become
true naturally. Do not force it by lying.
═══════════════════════════════════════════════════════════
Please work on the task. When you try to exit, the Ralph loop will feed the SAME PROMPT back to you for the next iteration. You'll see your previous work in files and git history, allowing you to iterate and improve.
CRITICAL RULE: If a completion promise is set, you may ONLY output it when the statement is completely and unequivocally TRUE. Do not output false promises to escape the loop, even if you think you're stuck or should exit for other reasons. The loop is designed to continue until genuine completion.
I'll start by reading the ROADMAP.md to understand what work is outstanding.
This is a comprehensive ROADMAP. Let me understand the current state and identify the first task to work on. The phased implementation plan suggests:
Phase 1: Make the Engine Functional
These are the highest impact items. Let me start with Combat Integration since it affects 800+ cards and is the most critical gap. Let me first explore the current state of the codebase to understand what exists.
Let me read the key files to understand the current state:
combat.rs - already has combat functions
game.rs - needs combat wired in
The PlayerDecisionMaker trait needs implementations for choose_attackers() and choose_blockers() to handle the decision-making logic during combat phases.
This is a large roadmap. The highest-impact item is Combat Integration (Phase 1, Item 1), which affects 800+ cards. Let me start by exploring the current codebase to understand what exists and what needs to be wired in.
Excellent! Now I have a thorough understanding of the codebase. Let me plan the combat integration work:
CombatState to GameState - The combat state needs to be part of the game stateturn_based_actions():
Now I need to examine the specific sections of game.rs and state.rs that I'll be modifying.
Now I have a comprehensive understanding. Let me read the specific sections I need to modify.
Now I have a thorough understanding of the codebase. Let me also read the game.rs to understand how damage dealing works and the permanent struct to understand what methods are available on it.
Let me read some more context needed:
Let me read more context needed to implement combat integration.
Found 6 files mtg-rl/mtg-engine/src/game.rs mtg-rl/mtg-tests/src/scripted_player.rs mtg-rl/mtg-ai/src/minimax_player.rs mtg-rl/mtg-ai/src/heuristic_player.rs mtg-rl/mtg-ai/src/random_player.rs mtg-rl/mtg-engine/src/decision.rs
Let me read the permanent methods for combat (can_attack, can_block, has_*, power, toughness, etc.), the random_player and heuristic_player implementations to see how they implement select_attackers/select_blockers, and more of game.rs to understand the deal_damage and related methods.
<persisted-output>
Output too large (34.1KB). Full output saved to: /mnt/dest-btrfs/home/jc/.claude/projects/-mnt-dest-btrfs-home-jc-src-mage/22581bf4-495c-419a-b253-a5c55ec6eaac/tool-results/toolu_01EsPUKHZRALuN4JnocpgJDw.txt
Preview (first 2KB):
mtg-rl/mtg-ai/src/minimax_player.rs:343: fn select_attackers(
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-344- &mut self,
mtg-rl/mtg-ai/src/minimax_player.rs-345- game: &GameView<'_>,
mtg-rl/mtg-ai/src/minimax_player.rs-346- possible_attackers: &[ObjectId],
mtg-rl/mtg-ai/src/minimax_player.rs-347- possible_defenders: &[ObjectId],
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-348- ) -> Vec<(ObjectId, ObjectId)> {
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-349- // In the full implementation, attacker selection would use search
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-350- // to evaluate different attacker combinations. For now, use heuristic.
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-351- self.heuristic
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-352- .select_attackers(game, possible_attackers, possible_defenders)
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-353- }
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-354-
mtg-rl/mtg-ai/src/minimax_player.rs:355: fn select_blockers(
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-356- &mut self,
mtg-rl/mtg-ai/src/minimax_player.rs-357- game: &GameView<'_>,
mtg-rl/mtg-ai/src/minimax_player.rs-358- attackers: &[AttackerInfo],
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-359- ) -> Vec<(ObjectId, ObjectId)> {
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-360- self.heuristic.select_blockers(game, attackers)
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-361- }
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-362-
/mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-ai/src/minimax_player.rs-363- fn assign_damage(
...
</persisted-output>Now I have everything I need to understand the codebase. Let me also look at deal_damage in game.rs to see how damage is currently applied:
Now let me look at how damage dealing works in the game loop, and also how process_state_based_actions works since we'll need to ensure SBAs fire after combat damage.
771: pub fn process_state_based_actions(&mut self) {
772- for _ in 0..MAX_SBA_ITERATIONS {
773- let sba = self.state.check_state_based_actions();
774- if !sba.has_actions() {
775- break;
776- }
777- self.apply_state_based_actions(&sba);
778- }
779- }
780-
781- /// Apply the detected state-based actions.
782- fn apply_state_based_actions(&mut self, sba: &StateBasedActions) {
783- // Players losing the game
784- for &pid in &sba.players_losing {
785- if let Some(player) = self.state.players.get_mut(&pid) {
786- player.lost = true;
787- }
788- }
789-
790- // Permanents going to graveyard (0 toughness)
791- for &perm_id in &sba.permanents_to_graveyard {
792- if let Some(perm) = self.state.battlefield.remove(perm_id) {
793- let owner = perm.owner();
794- self.state.ability_store.remove_source(perm_id);
795- self.move_card_to_graveyard(perm_id, owner);
796- }
797- }
798-
799- // Permanents being destroyed (lethal damage)
800- for &perm_id in &sba.permanents_to_destroy {
801- if let Some(perm) = self.state.battlefield.remove(perm_id) {No matches found
1105: Effect::DealDamage { amount } => {
1106- // Deal damage to target permanents.
1107- // Player targeting is handled separately via SelectedTargets.
1108- for &target_id in targets {
1109- if let Some(perm) = self.state.battlefield.get_mut(target_id) {
1110- perm.apply_damage(*amount);
1111- }
1112- }
1113- // If no permanent targets, deal damage to opponents
1114- // (simplified for "deal N damage to target opponent" effects)
1115- if targets.is_empty() {
1116- if let Some(opp_id) = self.state.opponent_of(controller) {
1117- if let Some(opp) = self.state.players.get_mut(&opp_id) {
1118- opp.life -= *amount as i32;
1119- }
1120- }
1121- }
1122- }
1123- Effect::Destroy => {
1124- for &target_id in targets {
1125- if let Some(perm) = self.state.battlefield.get(target_id) {
--
1194: Effect::DealDamageOpponents { amount } => {
1195- let opponents: Vec<PlayerId> = self.state.turn_order.iter()
1196- .filter(|&&id| id != controller)
1197- .copied()
1198- .collect();
1199- for opp in opponents {
1200- if let Some(player) = self.state.players.get_mut(&opp) {
1201- player.life -= *amount as i32;
1202- }
1203- }
1204- }
1205- Effect::AddCounters { counter_type, count } => {
1206- let ct = crate::counters::CounterType::from_name(counter_type);
1207- // If no targets, fall back to source (self-targeting counters)
1208- let effective_targets: Vec<ObjectId> = if targets.is_empty() {
1209- source.into_iter().collect()
1210- } else {
1211- targets.to_vec()
1212- };
1213- for target_id in effective_targets {
1214- if let Some(perm) = self.state.battlefield.get_mut(target_id) {
--
1481: Effect::DealDamageAll { amount, filter } => {
1482- // Deal damage to all creatures matching filter
1483- let matching: Vec<ObjectId> = self.state.battlefield.iter()
1484- .filter(|p| p.is_creature() && Self::matches_filter(p, filter))
1485- .map(|p| p.id())
1486- .collect();
1487- for id in matching {
1488- if let Some(perm) = self.state.battlefield.get_mut(id) {
1489- perm.apply_damage(*amount);
1490- }
1491- }
1492- }
1493- Effect::RemoveCounters { counter_type, count } => {
1494- let ct = crate::counters::CounterType::from_name(counter_type);
1495- // If no targets, fall back to source (self-targeting counters)
1496- let effective_targets: Vec<ObjectId> = if targets.is_empty() {
1497- source.into_iter().collect()
1498- } else {
1499- targets.to_vec()
1500- };
1501- for target_id in effective_targets {
--
1777: Effect::DealDamageVivid => {
1778- let x = self.count_colors_among_permanents(controller) as u32;
1779- if x > 0 {
1780- for &target_id in targets {
1781- if let Some(perm) = self.state.battlefield.get_mut(target_id) {
1782- perm.apply_damage(x);
1783- }
1784- }
1785: // If no permanent targets, deal to opponent (same pattern as DealDamage)
1786- if targets.is_empty() {
1787- if let Some(opp_id) = self.state.opponent_of(controller) {
1788- if let Some(opp) = self.state.players.get_mut(&opp_id) {
1789- opp.life -= x as i32;
1790- }
1791- }
1792- }
1793- }
1794- }
1795- Effect::GainLifeVivid => {
1796- let x = self.count_colors_among_permanents(controller) as u32;
1797- if let Some(player) = self.state.players.get_mut(&controller) {
1798- player.gain_life(x);
1799- }
1800- }
1801- Effect::BoostUntilEotVivid => {
1802- let x = self.count_colors_among_permanents(controller) as i32;
1803- for &target_id in targets {
1804- if let Some(perm) = self.state.battlefield.get_mut(target_id) {
1805- perm.card.power = perm.card.power.map(|p| p + x);
--
2670: vec![Effect::DealDamage { amount: 1 }],
2671- TargetSpec::CreatureOrPlayer,
2672- );
2673- let ability_id = ability.id;
2674- card.abilities.push(ability);
2675-
2676- let perm = Permanent::new(card.clone(), p1);
2677- perm.id(); // verify it has the right ID
2678- game.state.battlefield.add(perm);
2679- game.state.card_store.insert(card.clone());
2680- for a in &card.abilities {
2681- game.state.ability_store.add(a.clone());
2682- }
2683-
2684- // Remove summoning sickness for the test
2685- if let Some(perm) = game.state.battlefield.get_mut(source_id) {
2686- perm.remove_summoning_sickness();
2687- }
2688-
2689- // Activate the ability
2690- game.activate_ability(p1, source_id, ability_id, &[]);
--
2737: vec![Effect::DealDamage { amount: 3 }],
2738- TargetSpec::CreatureOrPlayer,
2739- ));
2740-
2741- let stack_item = crate::zones::StackItem {
2742- id: bolt_id,
2743- kind: crate::zones::StackItemKind::Spell { card: bolt },
2744- controller: p1,
2745- targets: vec![bear_id],
2746- countered: false,
2747- };
2748- game.state.stack.push(stack_item);
2749-
2750- // Resolve the spell
2751- game.resolve_top_of_stack();
2752-
2753- // The bear should have 3 damage marked on it
2754- let bear = game.state.battlefield.get(bear_id).unwrap();
2755- assert_eq!(bear.damage, 3);
2756- assert!(bear.has_lethal_damage());
2757-
--
4126: game.execute_effects(&[Effect::DealDamageVivid], p1, &[target], None);
4127- assert_eq!(game.state.battlefield.get(target).unwrap().damage, 2);
4128- }
4129-
4130- #[test]
4131- fn vivid_boost_until_eot() {
4132- let (mut game, p1, _) = setup();
4133- // p1 has 3 colors
4134- add_colored_creature(&mut game, p1, "R", "{R}");
4135- add_colored_creature(&mut game, p1, "G", "{G}");
4136- let target = add_colored_creature(&mut game, p1, "B", "{B}");
4137-
4138- game.execute_effects(&[Effect::BoostUntilEotVivid], p1, &[target], None);
4139- let perm = game.state.battlefield.get(target).unwrap();
4140- assert_eq!(perm.power(), 5); // 2 base + 3 vivid
4141- assert_eq!(perm.toughness(), 5);
4142- }
4143-
4144- #[test]
4145- fn vivid_create_tokens() {
4146- let (mut game, p1, _) = setup();Now I have a thorough understanding of the codebase. Let me plan the combat integration:
CombatState to GameState — Add a combat field to GameStateturn_based_actions():
DeclareAttackers:
select_attackers() on decision makerCombatStateDeclareAttacker eventsDeclareBlockers:
AttackerInfo for each attacker with legal blockersselect_blockers() on each defending player's decision maker, register blockers, validate menace requirements, and emit DeclareBlocker eventsFirstStrikeDamage:
assign_combat_damage(), apply it to permanents and players, and handle lifelink by gaining life equal to damage dealtCombatDamage:
EndCombat:
CombatState is imported and used in game.rs, and verify the combat module is already accessibleLet me check the module structure and imports in game.rs.
Good - the combat module is already in lib.rs. Now I have all the context I need. Let me implement combat integration. This is a large task, so let me break it down:
CombatState to GameStateturn_based_actions() in game.rsLet me start implementing. First, add CombatState to GameState.
Now I have all the context I need. Let me implement the combat integration step by step.
Plan:
combat: CombatState to GameState struct and initialize it in new()combat module in state.rsturn_based_actions() in game.rsCombatState and related types in game.rsLet me start with the changes:
Add use crate::combat::CombatState;
Add the combat field to the struct and initialize it when creating a new game state
turn_based_actions() functionNow I'll write the implementation code.
Now I have everything I need. Let me implement combat integration step by step.
Step 1: Add CombatState to GameState
Now add combat: CombatState::new() to the new() constructor:
Step 2: Implement combat steps in game.rs
First, add the imports needed:
Now implement the combat steps. Let me replace the _ => catch-all in turn_based_actions():
Now let me check if the gain_life method exists on Player:
115: pub fn gain_life(&mut self, amount: u32) -> i32 {
116- self.life += amount as i32;
117- self.life
118- }
119-
120- /// Lose life. Returns the new life total.
--
207: pub fn is_in_game(&self) -> bool {
208- !self.lost && !self.won && !self.drawn && !self.conceded
209- }
210-
211- // ── Turn reset ───────────────────────────────────────────────────────
212-Good. Now let's check if it compiles:
cd /mnt/dest-btrfs/home/jc/src/mage/mtg-rl && cargo check -p mtg-engine 2>&1 | head -60It compiles with just one unused import warning. Let me fix that:
sed -i 's/use crate::constants::{KeywordAbilities, PhaseStep};/use crate::constants::PhaseStep;/' mtg-engine/src/game.rscargo check -p mtg-engine 2>&1Clean compile. Now let me check the rest of the workspace:
cargo check 2>&1 | head -30cargo check 2>&1 | tail -10Full workspace compiles. Now let me run existing tests to make sure nothing is broken:
cargo test --lib 2>&1 | tail -30All 19 tests pass. Let me also check the engine tests:
cargo test -p mtg-engine --lib 2>&1 | tail -50All 188 engine tests pass. Now let me write tests for the combat integration. Let me find a good spot to add them in game.rs tests:
Let me find the end of the test module to add combat tests:
wc -l /mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-engine/src/game.rsNow I need to look at the test setup pattern to understand how to construct a game for combat testing. Let me read a nearby test setup:
Good. Now I understand the test pattern. I need to write combat tests that create a game, manually set up the battlefield, and then call the combat step methods directly. Let me add tests at the end of the test module.
Now let me understand how to set up a quick test game. I'll need a helper function that creates a game and lets me add creatures to the battlefield, then I'll test combat directly. Let me add the tests:
cat >> /mnt/dest-btrfs/home/jc/src/mage/mtg-rl/mtg-engine/src/game.rs << 'RUSTEOF'
#[cfg(test)]
mod combat_tests {
use super::*;
use crate::card::CardData;
use crate::constants::{CardType, KeywordAbilities, Outcome};
use crate::decision::{
AttackerInfo, DamageAssignment, GameView, NamedChoice, PlayerAction,
ReplacementEffectChoice, TargetRequirement, UnpaidMana,
};
/// Decision maker that attacks with all creatures.
struct AttackAllPlayer;
impl PlayerDecisionMaker for AttackAllPlayer {
fn priority(&mut self, _: &GameView<'_>, _: &[PlayerAction]) -> PlayerAction {
PlayerAction::Pass
}
fn choose_targets(&mut self, _: &GameView<'_>, _: Outcome, _: &TargetRequirement) -> Vec<ObjectId> { vec![] }
fn choose_use(&mut self, _: &GameView<'_>, _: Outcome, _: &str) -> bool { false }
fn choose_mode(&mut self, _: &GameView<'_>, _: &[NamedChoice]) -> usize { 0 }
fn select_attackers(
&mut self,
_: &GameView<'_>,
possible_attackers: &[ObjectId],
possible_defenders: &[ObjectId],
) -> Vec<(ObjectId, ObjectId)> {
let defender = possible_defenders[0];
possible_attackers
.iter()
.map(|&a| (a, defender))
.collect()
}
fn select_blockers(&mut self, _: &GameView<'_>, _: &[AttackerInfo]) -> Vec<(ObjectId, ObjectId)> { vec![] }
fn assign_damage(&mut self, _: &GameView<'_>, _: &DamageAssignment) -> Vec<(ObjectId, u32)> { vec![] }
fn choose_mulligan(&mut self, _: &GameView<'_>, _: &[ObjectId]) -> bool { false }
fn choose_cards_to_put_back(&mut self, _: &GameView<'_>, _: &[ObjectId], _: usize) -> Vec<ObjectId> { vec![] }
fn choose_discard(&mut self, _: &GameView<'_>, _: &[ObjectId], _: usize) -> Vec<ObjectId> { vec![] }
fn choose_amount(&mut self, _: &GameView<'_>, _: &str, min: u32, _: u32) -> u32 { min }
fn choose_mana_payment(&mut self, _: &GameView<'_>, _: &UnpaidMana, _: &[PlayerAction]) -> Option<PlayerAction> { None }
fn choose_replacement_effect(&mut self, _: &GameView<'_>, _: &[ReplacementEffectChoice]) -> usize { 0 }
fn choose_pile(&mut self, _: &GameView<'_>, _: Outcome, _: &str, _: &[ObjectId], _: &[ObjectId]) -> bool { true }
fn choose_option(&mut self, _: &GameView<'_>, _: Outcome, _: &str, _: &[NamedChoice]) -> usize { 0 }
}
/// Decision maker that blocks with all available creatures (first available for each attacker).
struct BlockAllPlayer;
impl PlayerDecisionMaker for BlockAllPlayer {
fn priority(&mut self, _: &GameView<'_>, _: &[PlayerAction]) -> PlayerAction {
PlayerAction::Pass
}
fn choose_targets(&mut self, _: &GameView<'_>, _: Outcome, _: &TargetRequirement) -> Vec<ObjectId> { vec![] }
fn choose_use(&mut self, _: &GameView<'_>, _: Outcome, _: &str) -> bool { false }
fn choose_mode(&mut self, _: &GameView<'_>, _: &[NamedChoice]) -> usize { 0 }
fn select_attackers(&mut self, _: &GameView<'_>, _: &[ObjectId], _: &[ObjectId]) -> Vec<(ObjectId, ObjectId)> { vec![] }
fn select_blockers(
&mut self,
_: &GameView<'_>,
attackers: &[AttackerInfo],
) -> Vec<(ObjectId, ObjectId)> {
let mut blocks = Vec::new();
let mut used = std::collections::HashSet::new();
for info in attackers {
for &blocker_id in &info.legal_blockers {
if !used.contains(&blocker_id) {
blocks.push((blocker_id, info.attacker_id));
used.insert(blocker_id);
break;
}
}
}
blocks
}
fn assign_damage(&mut self, _: &GameView<'_>, _: &DamageAssignment) -> Vec<(ObjectId, u32)> { vec![] }
fn choose_mulligan(&mut self, _: &GameView<'_>, _: &[ObjectId]) -> bool { false }
fn choose_cards_to_put_back(&mut self, _: &GameView<'_>, _: &[ObjectId], _: usize) -> Vec<ObjectId> { vec![] }
fn choose_discard(&mut self, _: &GameView<'_>, _: &[ObjectId], _: usize) -> Vec<ObjectId> { vec![] }
fn choose_amount(&mut self, _: &GameView<'_>, _: &str, min: u32, _: u32) -> u32 { min }
fn choose_mana_payment(&mut self, _: &GameView<'_>, _: &UnpaidMana, _: &[PlayerAction]) -> Option<PlayerAction> { None }
fn choose_replacement_effect(&mut self, _: &GameView<'_>, _: &[ReplacementEffectChoice]) -> usize { 0 }
fn choose_pile(&mut self, _: &GameView<'_>, _: Outcome, _: &str, _: &[ObjectId], _: &[ObjectId]) -> bool { true }
fn choose_option(&mut self, _: &GameView<'_>, _: Outcome, _: &str, _: &[NamedChoice]) -> usize { 0 }
}
fn make_creature(
name: &str,
owner: PlayerId,
power: i32,
toughness: i32,
keywords: KeywordAbilities,
) -> CardData {
let mut card = CardData::new(ObjectId::new(), owner, name);
card.card_types = vec![CardType::Creature];
card.power = Some(power);
card.toughness = Some(toughness);
card.keywords = keywords;
card
}
fn make_deck(owner: PlayerId) -> Vec<CardData> {
(0..40)
.map(|i| {
let mut c = CardData::new(ObjectId::new(), owner, &format!("Card {i}"));
c.card_types = vec![CardType::Land];
c
})
.collect()
}
fn setup_combat_game(
p1_dm: Box<dyn PlayerDecisionMaker>,
p2_dm: Box<dyn PlayerDecisionMaker>,
) -> (Game, PlayerId, PlayerId) {
let p1 = PlayerId::new();
let p2 = PlayerId::new();
let config = GameConfig {
players: vec![
PlayerConfig {
name: "Attacker".to_string(),
deck: make_deck(p1),
},
PlayerConfig {
name: "Defender".to_string(),
deck: make_deck(p2),
},
],
starting_life: 20,
};
let game = Game::new_two_player(config, vec![(p1, p1_dm), (p2, p2_dm)]);
(game, p1, p2)
}
/// Helper to add a creature to the battlefield and remove summoning sickness.
fn add_creature(
game: &mut Game,
owner: PlayerId,
name: &str,
power: i32,
toughness: i32,
keywords: KeywordAbilities,
) -> ObjectId {
let card = make_creature(name, owner, power, toughness, keywords);
let id = card.id;
let mut perm = Permanent::new(card, owner);
perm.remove_summoning_sickness();
game.state.battlefield.add(perm);
id
}
// ── Test: Unblocked combat damage ──────────────────────────────
#[test]
fn unblocked_attacker_deals_damage_to_player() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
// Add a 3/3 creature for p1 (attacker)
let bear_id = add_creature(&mut game, p1, "Bear", 3, 3, KeywordAbilities::empty());
// Set active player to p1
game.state.active_player = p1;
// Run declare attackers step
game.declare_attackers_step(p1);
// Bear should be attacking
assert!(game.state.combat.is_attacking(bear_id));
// Bear should be tapped (no vigilance)
assert!(game.state.battlefield.get(bear_id).unwrap().tapped);
// Run declare blockers (no blockers for p2)
game.declare_blockers_step(p1);
// Run combat damage (regular)
game.combat_damage_step(false);
// p2 should have taken 3 damage
assert_eq!(game.state.players[&p2].life, 17);
}
#[test]
fn vigilance_does_not_tap_attacker() {
let (mut game, p1, _p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
let vig_id = add_creature(&mut game, p1, "Vigilant", 2, 2, KeywordAbilities::VIGILANCE);
game.state.active_player = p1;
game.declare_attackers_step(p1);
// Should be attacking but NOT tapped
assert!(game.state.combat.is_attacking(vig_id));
assert!(!game.state.battlefield.get(vig_id).unwrap().tapped);
}
#[test]
fn blocked_creature_deals_damage_to_blocker() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
let attacker_id = add_creature(&mut game, p1, "Attacker", 3, 3, KeywordAbilities::empty());
let blocker_id = add_creature(&mut game, p2, "Blocker", 2, 4, KeywordAbilities::empty());
game.state.active_player = p1;
game.declare_attackers_step(p1);
game.declare_blockers_step(p1);
// Blocker should be blocking
assert!(game.state.combat.is_blocking(blocker_id));
// Apply combat damage
game.combat_damage_step(false);
// Blocker should have 3 damage, attacker should have 2 damage
assert_eq!(game.state.battlefield.get(blocker_id).unwrap().damage, 3);
assert_eq!(game.state.battlefield.get(attacker_id).unwrap().damage, 2);
// Player should NOT have taken damage (blocked)
assert_eq!(game.state.players[&p2].life, 20);
}
#[test]
fn lifelink_gains_life_on_combat_damage() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
add_creature(&mut game, p1, "Lifelinker", 4, 4, KeywordAbilities::LIFELINK);
game.state.active_player = p1;
// Reduce p1 life to verify gain
game.state.players.get_mut(&p1).unwrap().life = 15;
game.declare_attackers_step(p1);
game.declare_blockers_step(p1);
game.combat_damage_step(false);
// p1 should gain 4 life (lifelink), p2 takes 4
assert_eq!(game.state.players[&p1].life, 19);
assert_eq!(game.state.players[&p2].life, 16);
}
#[test]
fn first_strike_deals_damage_first() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
let fs_id = add_creature(&mut game, p1, "FirstStriker", 3, 2, KeywordAbilities::FIRST_STRIKE);
let blocker_id = add_creature(&mut game, p2, "Blocker", 3, 3, KeywordAbilities::empty());
game.state.active_player = p1;
game.declare_attackers_step(p1);
game.declare_blockers_step(p1);
// First strike damage step
game.combat_damage_step(true);
// Blocker takes 3 first strike damage
assert_eq!(game.state.battlefield.get(blocker_id).unwrap().damage, 3);
// First striker takes 0 (normal creature doesn't deal in first strike step)
assert_eq!(game.state.battlefield.get(fs_id).unwrap().damage, 0);
// Regular damage step
game.combat_damage_step(false);
// First striker still takes 0 more (first strike creature doesn't deal in regular step)
// But blocker deals its 3 damage to first striker in regular step
assert_eq!(game.state.battlefield.get(fs_id).unwrap().damage, 3);
}
#[test]
fn trample_overflow_to_player() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
add_creature(&mut game, p1, "Trampler", 5, 5, KeywordAbilities::TRAMPLE);
let blocker_id = add_creature(&mut game, p2, "SmallBlocker", 1, 2, KeywordAbilities::empty());
game.state.active_player = p1;
game.declare_attackers_step(p1);
game.declare_blockers_step(p1);
game.combat_damage_step(false);
// Blocker takes 2 damage (lethal), 3 tramples to player
assert_eq!(game.state.battlefield.get(blocker_id).unwrap().damage, 2);
assert_eq!(game.state.players[&p2].life, 17);
}
#[test]
fn end_combat_clears_state() {
let (mut game, p1, _p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
add_creature(&mut game, p1, "Bear", 2, 2, KeywordAbilities::empty());
game.state.active_player = p1;
game.declare_attackers_step(p1);
assert!(game.state.combat.has_attackers());
// End combat clears state
game.state.combat.clear();
assert!(!game.state.combat.has_attackers());
}
#[test]
fn defender_cannot_attack() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
// Only a defender creature
add_creature(&mut game, p1, "Wall", 0, 5, KeywordAbilities::DEFENDER);
game.state.active_player = p1;
game.declare_attackers_step(p1);
// Should have no attackers (defender can't attack)
assert!(!game.state.combat.has_attackers());
assert_eq!(game.state.players[&p2].life, 20);
}
#[test]
fn summoning_sick_creature_cannot_attack() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
// Add creature WITH summoning sickness (don't remove it)
let card = make_creature("SickBear", p1, 3, 3, KeywordAbilities::empty());
let perm = Permanent::new(card, p1);
game.state.battlefield.add(perm);
game.state.active_player = p1;
game.declare_attackers_step(p1);
// Should not have attacked
assert!(!game.state.combat.has_attackers());
assert_eq!(game.state.players[&p2].life, 20);
}
#[test]
fn haste_bypasses_summoning_sickness() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
// Add creature with haste and summoning sickness
let card = make_creature("Hasty", p1, 2, 1, KeywordAbilities::HASTE);
let perm = Permanent::new(card, p1);
game.state.battlefield.add(perm);
game.state.active_player = p1;
game.declare_attackers_step(p1);
game.declare_blockers_step(p1);
game.combat_damage_step(false);
// Should have attacked and dealt damage
assert_eq!(game.state.players[&p2].life, 18);
}
#[test]
fn flying_cannot_be_blocked_by_ground() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
add_creature(&mut game, p1, "Flyer", 3, 3, KeywordAbilities::FLYING);
// Ground creature cannot block a flyer
add_creature(&mut game, p2, "Ground", 2, 2, KeywordAbilities::empty());
game.state.active_player = p1;
game.declare_attackers_step(p1);
game.declare_blockers_step(p1);
game.combat_damage_step(false);
// Flyer should get through unblocked
assert_eq!(game.state.players[&p2].life, 17);
}
#[test]
fn reach_can_block_flying() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
let flyer_id = add_creature(&mut game, p1, "Flyer", 2, 2, KeywordAbilities::FLYING);
let reacher_id = add_creature(&mut game, p2, "Reacher", 1, 4, KeywordAbilities::REACH);
game.state.active_player = p1;
game.declare_attackers_step(p1);
game.declare_blockers_step(p1);
// Reacher should be blocking the flyer
assert!(game.state.combat.is_blocking(reacher_id));
game.combat_damage_step(false);
// No damage to player
assert_eq!(game.state.players[&p2].life, 20);
// Creatures trade damage
assert_eq!(game.state.battlefield.get(reacher_id).unwrap().damage, 2);
assert_eq!(game.state.battlefield.get(flyer_id).unwrap().damage, 1);
}
#[test]
fn multiple_attackers_deal_combined_damage() {
let (mut game, p1, p2) = setup_combat_game(
Box::new(AttackAllPlayer),
Box::new(BlockAllPlayer),
);
add_creature(&mut game, p1, "Bear1", 2, 2, KeywordAbilities::empty());
add_creature(&mut game, p1, "Bear2", 3, 3, KeywordAbilities::empty());
game.state.active_player = p1;
game.declare_attackers_step(p1);
game.declare_blockers_step(p1);
game.combat_damage_step(false);
// Both should deal damage: 2 + 3 = 5
assert_eq!(game.state.players[&p2].life, 15);
}
}
RUSTEOFcargo test -p mtg-engine --lib combat_tests 2>&1All 13 combat tests pass. Let me run the full test suite to make sure nothing is broken:
cargo test --lib 2>&1 | tail -30All tests pass (201 engine + 19 integration = 220 total). Now let me update the ROADMAP.md to reflect this completed work:
Now update the step table and the combat integration section in the phased plan:
Now update the Phase 1 section:
Now commit this change:
git statusgit diff --statcd /mnt/dest-btrfs/home/jc/src/mage && git add mtg-rl/ROADMAP.md mtg-rl/mtg-engine/src/game.rs mtg-rl/mtg-engine/src/state.rs && git commit -m "$(cat <<'EOF'
Wire combat phase into game loop: declare attackers/blockers, combat damage
- Add CombatState to GameState for tracking combat phase
- Implement DeclareAttackers step: prompts active player, taps attackers
(respects vigilance), validates legal attackers
- Implement DeclareBlockers step: prompts defending player, validates
flying/reach blocking restrictions
- Implement FirstStrikeDamage and CombatDamage steps: assigns damage via
combat.rs functions, applies to permanents/players, handles lifelink
- Implement EndCombat step: clears combat state
- Add 13 unit tests covering unblocked damage, blocked damage, vigilance,
lifelink, first strike, trample, defender, summoning sickness, haste,
flying/reach, multiple attackers
- Update ROADMAP.md to mark combat integration as done
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"Combat integration is complete and committed. Now let me move on to the next ROADMAP item: Triggered Ability Stacking (Phase 1, Item 2). This is the second highest-impact item, affecting ~400+ cards.
Let me explore the current state of the triggered ability system:
Excellent. Now I have a thorough understanding of the triggered ability system. Let me plan the implementation:
Let me also check what resolve_top_of_stack does with ability stack items to understand how effects get executed when an ability resolves:
Excellent understanding. Let me now read the key sections I need to modify for triggered ability stacking - especially how abilities resolve from the stack:
956: fn resolve_top_of_stack(&mut self) {
957- let item = match self.state.stack.pop() {
958- Some(item) => item,
959- None => return,
960- };
961-
962- if item.countered {
963- // Countered spells go to graveyard
964- self.move_card_to_graveyard(item.id, item.controller);
965- return;
966- }
967-
968- / Fizzle check: if the spell/ability has targets and all targets are
969- // now illegal (left the battlefield, gained hexproof, etc.), it fizzles.
970- / Note: targets stored as ObjectIds; player targets are tracked separately
971- // in the SelectedTargets system (targets.rs). This simplified check only
972- // validates permanent targets on the battlefield.
973- if !item.targets.is_empty() {
974- let any_legal = item.targets.iter().any(|&target_id| {
975- self.state.battlefield.contains(target_id)
976- || self.state.stack.get(target_id).is_some()
977- });
978- if !any_legal {
979- // All targets are illegal — fizzle
980- match &item.kind {
981- crate::zones::StackItemKind::Spell { .. } => {
982- self.move_card_to_graveyard(item.id, item.controller);
983- }
984: crate::zones::StackItemKind::Ability { .. } => {
985- // Abilities just cease to exist when fizzled
986- }
987- }
988- return;
989- }
990- }
991-
992- match &item.kind {
993- crate::zones::StackItemKind::Spell { card } => {
994- if card.is_permanent_card() {
995- // Register abilities from the card
996- for ability in &card.abilities {
997- self.state.ability_store.add(ability.clone());
998- }
999- // Permanent spells enter the battlefield
1000- let perm = Permanent::new(card.clone(), item.controller);
1001- self.state.battlefield.add(perm);
1002- self.state.set_zone(item.id, crate::constants::Zone::Battlefield, None);
1003- } else {
1004- / Non-permanent spells: execute effects then go to graveyard
1005- let effects: Vec<Effect> = card.abilities.iter()
1006- .flat_map(|a| a.effects.clone())
1007- .collect();
1008- let targets = item.targets.clone();
1009- self.execute_effects(&effects, item.controller, &targets, Some(item.id));
1010- self.move_card_to_graveyard(item.id, item.controller);
1011- }
1012- }
1013: crate::zones::StackItemKind::Ability { ability_id, source_id, .. } => {
1014- / Resolve ability: find its effects and execute them
1015- let source = *source_id;
1016- let ability_data = self.state.ability_store.get(*ability_id).cloned();
1017- if let Some(ability) = ability_data {
1018- let targets = item.targets.clone();
1019- self.execute_effects(&ability.effects, item.controller, &targets, Some(source));
1020- }
1021- }
1022- }
1023- }
1024-
1025- /// Process state-based actions in a loop until no more are found.
1026- pub fn process_state_based_actions(&mut self) {
1027- for _ in 0..MAX_SBA_ITERATIONS {
1028- let sba = self.state.check_state_based_actions();
1029- if !sba.has_actions() {
1030- break;
1031- }
1032- self.apply_state_based_actions(&sba);
1033- }
1034- }
1035-
1036- /// Apply the detected state-based actions.
1037- fn apply_state_based_actions(&mut self, sba: &StateBasedActions) {
1038- // Players losing the game
1039- for &pid in &sba.players_losing {
1040- if let Some(player) = self.state.players.get_mut(&pid) {
1041- player.lost = true;
1042- }
1043- }
--
1098: kind: crate::zones::StackItemKind::Ability {
1099- source_id,
1100- ability_id,
1101- description: ability.rules_text.clone(),
1102- },
1103- controller: player_id,
1104- targets: targets.to_vec(),
1105- countered: false,
1106- };
1107- self.state.stack.push(stack_item);
1108- }
1109-
1110- /// Activate a mana ability (resolves immediately, doesn't use the stack).
1111- fn activate_mana_ability(
1112- &mut self,
1113- player_id: PlayerId,
1114- source_id: ObjectId,
1115- ability_id: AbilityId,
1116- ) {
1117- let ability = match self.state.ability_store.get(ability_id).cloned() {
1118- Some(a) => a,
1119- None => return,
1120- };
1121-
1122- if !ability.is_mana_ability() {
1123- return;
1124- }
1125-
1126- // Pay costs (typically just tap)
1127- if !self.pay_costs(player_id, source_id, &ability.costs) {
1128- return;Now let me read the AbilityStore methods more carefully, and how should_trigger works:
451: pub fn should_trigger(&self, event: &GameEvent) -> bool {
452- if self.ability_type != AbilityType::TriggeredNonMana {
453- return false;
454- }
455- self.trigger_events.contains(&event.event_type)
456- }
457-
458- /// Check if an activated ability can be activated in the given zone.
459- pub fn can_activate_in_zone(&self, zone: Zone) -> bool {
460- self.active_zones.contains(&zone)
461- }
462-
463- /// Make this a "may" trigger (optional).
464- pub fn set_optional(mut self) -> Self {
465- self.optional_trigger = true;
466- self
467- }
468-
469- /// Set the active zones for this ability.
470- pub fn in_zones(mut self, zones: Vec<Zone>) -> Self {
471- self.active_zones = zones;
--
1148:pub struct AbilityStore {
1149- /// All abilities, keyed by their unique AbilityId.
1150- abilities: std::collections::HashMap<AbilityId, Ability>,
1151- / Index: source ObjectId → list of AbilityIds.
1152- by_source: std::collections::HashMap<ObjectId, Vec<AbilityId>>,
1153-}
1154-
1155-impl AbilityStore {
1156- pub fn new() -> Self {
1157- Self::default()
1158- }
1159-
1160- /// Register an ability for a source object.
1161- pub fn add(&mut self, ability: Ability) {
1162- let id = ability.id;
1163- let source = ability.source_id;
1164- self.abilities.insert(id, ability);
1165- self.by_source.entry(source).or_default().push(id);
1166- }
1167-
1168- /// Get an ability by its ID.
--
1182: pub fn triggered_by(&self, event: &GameEvent) -> Vec<&Ability> {
1183- self.abilities
1184- .values()
1185- .filter(|a| a.should_trigger(event))
1186- .collect()
1187- }
1188-
1189- /// Get all mana abilities for a source.
1190- pub fn mana_abilities_for(&self, source_id: ObjectId) -> Vec<&Ability> {
1191- self.for_source(source_id)
1192- .into_iter()
1193- .filter(|a| a.is_mana_ability())
1194- .collect()
1195- }
1196-
1197- /// Remove all abilities for a source (e.g. when permanent leaves battlefield).
1198- pub fn remove_source(&mut self, source_id: ObjectId) {
1199- if let Some(ids) = self.by_source.remove(&source_id) {
1200- for id in ids {
1201- self.abilities.remove(&id);
1202- }Now I understand the full picture. The key thing I need to do:
The infrastructure is almost entirely in place. Let me look at where events need to be emitted - particularly during permanent ETB and death:
905: let perm = Permanent::new(card_data, player_id);
906: self.state.battlefield.add(perm);
907: self.state.set_zone(card_id, crate::constants::Zone::Battlefield, None);
908- }
909- }
910-
911- / Cast a spell (simplified: pay mana, move to stack, then resolve immediately
912- /// for now since the full stack resolution needs the ability framework).
913- fn cast_spell(&mut self, player_id: PlayerId, card_id: ObjectId) {
914- let card_data = match self.state.card_store.get(card_id).cloned() {
915- Some(c) => c,
916- None => return,
917- };
--
952: self.state.set_zone(card_id, crate::constants::Zone::Stack, None);
953- }
954-
955- /// Resolve the top item on the stack.
956- fn resolve_top_of_stack(&mut self) {
957- let item = match self.state.stack.pop() {
958- Some(item) => item,
959- None => return,
960- };
961-
962- if item.countered {
--
1000: let perm = Permanent::new(card.clone(), item.controller);
1001: self.state.battlefield.add(perm);
1002: self.state.set_zone(item.id, crate::constants::Zone::Battlefield, None);
1003- } else {
1004- / Non-permanent spells: execute effects then go to graveyard
1005- let effects: Vec<Effect> = card.abilities.iter()
1006- .flat_map(|a| a.effects.clone())
1007- .collect();
1008- let targets = item.targets.clone();
1009- self.execute_effects(&effects, item.controller, &targets, Some(item.id));
1010- self.move_card_to_graveyard(item.id, item.controller);
1011- }
1012- }
--
1192: self.state.set_zone(source_id, crate::constants::Zone::Graveyard, Some(owner));
1193- }
1194- } else {
1195- return false;
1196- }
1197- }
1198- Cost::Discard(count) => {
1199- let hand: Vec<ObjectId> = self.state.players.get(&player_id)
1200- .map(|p| p.hand.iter().copied().collect())
1201- .unwrap_or_default();
1202- if hand.len() < *count as usize {
--
1217: self.state.set_zone(card_id, crate::constants::Zone::Graveyard, Some(player_id));
1218- }
1219- }
1220- }
1221- Cost::RemoveCounters(counter_type_name, count) => {
1222- let ct = crate::counters::CounterType::from_name(counter_type_name);
1223- if let Some(perm) = self.state.battlefield.get_mut(source_id) {
1224- let current = perm.counters.get(&ct);
1225- if current < *count {
1226- return false; // Not enough counters
1227- }
--
1262: self.state.set_zone(card_id, crate::constants::Zone::Exile, None);
1263- }
1264- }
1265- Cost::ExileFromHand(count) => {
1266- let hand: Vec<ObjectId> = self.state.players.get(&player_id)
1267- .map(|p| p.hand.iter().copied().collect())
1268- .unwrap_or_default();
1269- if hand.len() < *count as usize {
1270- return false;
1271- }
1272- let view = crate::decision::GameView::placeholder();
--
1283: self.state.set_zone(card_id, crate::constants::Zone::Exile, None);
1284- }
1285- }
1286- Cost::SacrificeOther(filter) => {
[Showing results with pagination = limit: 80, offset: 0]2210: fn move_card_to_graveyard_inner(&mut self, card_id: ObjectId, owner: PlayerId) {
2211- if let Some(player) = self.state.players.get_mut(&owner) {
2212- player.graveyard.add(card_id);
2213- self.state.set_zone(card_id, crate::constants::Zone::Graveyard, Some(owner));
2214- }
2215- }
2216-
2217- /// Move a card to its owner's graveyard.
2218: fn move_card_to_graveyard(&mut self, card_id: ObjectId, owner: PlayerId) {
2219- if let Some(player) = self.state.players.get_mut(&owner) {
2220- player.graveyard.add(card_id);
2221- self.state.set_zone(card_id, crate::constants::Zone::Graveyard, Some(owner));
2222- }
2223- }
2224-
2225- /// Draw N cards for a player.
2226- pub fn draw_cards(&mut self, player_id: PlayerId, count: u32) {
2227- for _ in 0..count {
2228- let card_id = {
2229- let player = match self.state.players.get_mut(&player_id) {
2230- Some(p) => p,
2231- None => return,
2232- };
2233- match player.library.draw() {Now I need to implement triggered ability stacking. The approach is:
event_log to Game to track events fired during the current actionHowever, I need to be careful about the scope. The triggered ability system is complex, and I should implement it incrementally. Let me start with the simplest version that actually works:
For this first phase, I'll add event tracking to the game state, emit events for the most common triggers like ETB, death, damage to players, life gain, and attacks, then after state-based actions resolve, scan for any triggered abilities that match those events and push them onto the stack in the right order. I need to understand how process_step currently works and where the SBA loop fits in so I can integrate this properly, and there's a key design question about when exactly events should be emitted.
The simplest approach is to emit events right after the action happens: when a permanent enters the battlefield, when it dies, when damage is dealt to a player, when life is gained, and when combat happens. Let me add an event log field to the Game struct and modify those key locations to emit the appropriate events.
Now I'll implement triggered ability stacking. This is the second most impactful item. I need to:
GameLet me read the current process_step flow and the event log structure:
Good - EventLog already exists. Now let me look at the Game struct to add the event_log:
Now I have all the context. Let me implement triggered ability stacking. I'll modify game.rs to:
event_log field to Gamecheck_triggered_abilities() methodprocess_step() to loop SBAs + triggersNow add the event_log field to the Game struct:
Add event_log: EventLog::new() to the constructor:
Now modify process_step() to loop SBAs + triggered abilities:
Now I need to emit events at key locations. Let me add event emission to the key game actions:
Let me find and modify the ETB point in resolve_top_of_stack:
Let me find the resolve_top_of_stack more precisely:
1117: fn resolve_top_of_stack(&mut self) {Now I need to add event emission at key places. Let me modify resolve_top_of_stack to emit ETB events:
Now emit events for combat attacks in declare_attackers_step:
Let me find the attacker declaration part where I register attackers, and add event emission there:
694: .declare_attacker(*attacker_id, *defender_id, true);
695-
696- // Tap the attacker (unless it has vigilance)
697- if let Some(perm) = self.state.battlefield.get_mut(*attacker_id) {
698- if !perm.has_vigilance() {
699- perm.tap();
700- }
701- }
702- }
703-
704- // Check if any attackers have first/double strike to inform TurnManagerNow emit events for life gain. Let me find where GainLife is handled in execute_effects:
1597: Effect::GainLife { amount } => {
1598- if let Some(player) = self.state.players.get_mut(&controller) {
1599- player.life += *amount as i32;
1600- }
1601- }
1602- Effect::LoseLife { amount } => {
1603- // Controller loses life (target player effects will use
1604- // SelectedTargets for proper player targeting)
1605- if let Some(player) = self.state.players.get_mut(&controller) {
--
2221: Effect::GainLifeVivid => {
2222- let x = self.count_colors_among_permanents(controller) as u32;
2223- if let Some(player) = self.state.players.get_mut(&controller) {
2224- player.gain_life(x);
2225- }
2226- }
2227- Effect::BoostUntilEotVivid => {
2228- let x = self.count_colors_among_permanents(controller) as i32;
2229- for &target_id in targets {
--
3304: game.execute_effects(&[Effect::GainLife { amount: 5 }], p1, &[], None);
3305- assert_eq!(game.state.players.get(&p1).unwrap().life, 25);
3306- }
3307-
3308- #[test]
3309- fn lose_life_opponents_effect() {
3310- let p1 = PlayerId::new();
3311- let p2 = PlayerId::new();
3312-
--
4539: game.execute_effects(&[Effect::GainLifeVivid], p1, &[], None);
4540- assert_eq!(game.state.players[&p1].life, 23); // 20 + 3
4541- }
4542-
4543- #[test]
4544- fn vivid_deal_damage_to_creature() {
4545- let (mut game, p1, p2) = setup();
4546- // p1 has 2 colors
4547- add_colored_creature(&mut game, p1, "R", "{R}");
--
4673: vec![Effect::GainLife { amount: 3 }],
4674- vec![],
4675- );
4676-
4677- game.execute_effects(&[effect], p1, &[], Some(src_id));
4678-
4679- // AlwaysPayPlayer says yes, blight adds -1/-1, gain 3 life
4680- assert_eq!(game.state.battlefield.get(src_id).unwrap().counters.get(&CounterType::M1M1), 1);
4681- assert_eq!(game.state.players[&p1].life, 23); // 20 + 3Also emit for lifelink damage in combat:
865: // Apply lifelink
866- for (controller, amount) in &lifelink_sources {
867- if let Some(player) = self.state.players.get_mut(controller) {
868- player.gain_life(*amount);
869- }
870- }
871- }