Skip to main content

manabrew_engine/ability/effects/
roll_dice_effect.rs

1use super::{resolve_defined_players, resolve_numeric_svar, EffectContext};
2use std::collections::{HashMap, HashSet};
3
4use crate::agent::notification::GameNotification;
5use crate::agent::GameLogEvent;
6use crate::cost::{parse_cost, Cost, CostPart};
7use crate::event::RunParams;
8use crate::game::GameState;
9use crate::game_rng::GameRng;
10use crate::ids::PlayerId;
11use crate::mana;
12use crate::replacement::replacement_handler::{apply_replacements, ReplacementEvent};
13use crate::replacement::ReplacementResult;
14use crate::spellability::{build_spell_ability, SpellAbility};
15use crate::trigger::handler::TriggerHandler;
16use crate::trigger::TriggerType;
17use forge_foundation::ZoneType;
18
19/// Build a formatted description for a roll dice effect.
20/// Mirrors Java's `RollDiceEffect.makeFormatedDescription(SpellAbility)`.
21///
22/// Generates a human-readable description of the die roll including the
23/// number of sides and result sub-abilities.
24pub fn make_formated_description(game: &GameState, sa: &SpellAbility) -> String {
25    let source_id = match sa.source {
26        Some(id) => id,
27        None => return "Roll a die.".to_string(),
28    };
29
30    let sides = sa.ir.sides.unwrap_or(6);
31    let card_name = game.card(source_id).card_name.clone();
32
33    let mut desc = format!("{} — Roll a d{}.", card_name, sides);
34
35    if let Some(result_str) = sa.ir.result_sub_abilities_text.as_deref() {
36        desc.push('\n');
37        for entry in result_str.split(',') {
38            let parts: Vec<&str> = entry.splitn(2, ':').collect();
39            if parts.len() == 2 {
40                let threshold = parts[0].trim();
41                let svar_name = parts[1].trim();
42                if let Some(svar_text) = game.card(source_id).get_s_var(svar_name) {
43                    let params = crate::parsing::Params::from_raw(svar_text);
44                    let effect_desc = params
45                        .get_cloned(crate::parsing::keys::SPELL_DESCRIPTION)
46                        .unwrap_or_else(|| svar_name.to_string());
47                    desc.push_str(&format!("  {}: {}\n", threshold, effect_desc));
48                }
49            }
50        }
51    }
52
53    desc
54}
55
56/// Wrap raw natural rolls as `DieRollResult`s (natural == modified at start).
57/// Mirrors Java `RollDiceEffect.getResultsList(List<Integer>)`.
58pub fn get_results_list(natural_results: &[i32]) -> Vec<DieRollResult> {
59    natural_results
60        .iter()
61        .map(|&r| DieRollResult {
62            natural_value: r,
63            modified_value: r,
64        })
65        .collect()
66}
67
68/// Project `DieRollResult`s to their natural values.
69/// Mirrors Java `RollDiceEffect.getNaturalResults(List<DieRollResult>)`.
70pub fn get_natural_results(results: &[DieRollResult]) -> Vec<i32> {
71    results.iter().map(|r| r.natural_value).collect()
72}
73
74/// Project `DieRollResult`s to their post-modification values.
75/// Mirrors Java `RollDiceEffect.getFinalResults(List<DieRollResult>)`.
76pub fn get_final_results(results: &[DieRollResult]) -> Vec<i32> {
77    results.iter().map(|r| r.modified_value).collect()
78}
79
80/// Return the battlefield cards under `player` that can currently increment a
81/// die roll (Xenosquirrel via P1P1 counter, Night Shift via life payment).
82/// Mirrors Java `RollDiceEffect.getIncrementCards`.
83pub fn get_increment_cards(
84    game: &GameState,
85    player: PlayerId,
86    xeno_keyword: &str,
87    night_shift_keyword: &str,
88) -> Vec<crate::ids::CardId> {
89    let mut out = Vec::new();
90    let battlefield: Vec<_> = game.cards_in_zone(ZoneType::Battlefield, player).to_vec();
91
92    for cid in battlefield.iter().copied() {
93        let card = game.card(cid);
94        if card.has_keyword(xeno_keyword)
95            && card.counter_count(&crate::card::counter_type::CounterType::P1P1) > 0
96        {
97            out.push(cid);
98        }
99    }
100    for cid in battlefield {
101        let card = game.card(cid);
102        if !card.has_keyword(night_shift_keyword) {
103            continue;
104        }
105        // Java `canPayLife(1, true, ...)` — require at least 1 life available.
106        if game.player(player).life >= 1 {
107            out.push(cid);
108        }
109    }
110    out
111}
112
113/// Roll dice for a specific player.
114/// Mirrors Java's `RollDiceEffect.rollDiceForPlayer(Player, SpellAbility, ...)`.
115///
116/// This is a public wrapper around the internal `roll_for_player` function.
117pub fn roll_dice_for_player(
118    ctx: &mut EffectContext,
119    sa: &SpellAbility,
120    source_id: crate::ids::CardId,
121    player: PlayerId,
122    sides: i32,
123    amount: i32,
124) -> i32 {
125    roll_for_player(ctx, sa, source_id, player, sides, amount)
126}
127
128/// Roll dice for a player specifically to visit attractions.
129/// Mirrors Java's `RollDiceEffect.rollDiceForPlayerToVisitAttractions(Player)`.
130///
131/// Uses the full `roll_to_visit_attractions` function which handles
132/// replacement effects, roll modifiers, and attraction visiting.
133pub fn roll_dice_for_player_to_visit_attractions(
134    game: &mut GameState,
135    trigger_handler: &mut TriggerHandler,
136    rng: &mut (impl crate::game_rng::GameRng + ?Sized),
137    agents: &mut [Box<dyn crate::agent::PlayerAgent>],
138    mana_pools: &mut [crate::mana::ManaPool],
139    player: PlayerId,
140) {
141    roll_to_visit_attractions(game, trigger_handler, rng, agents, mana_pools, player);
142}
143
144/// `SP$ RollDice` — roll a die and resolve a sub-ability based on the result.
145///
146/// Mirrors Java's `RollDiceEffect.java`.
147/// - `Sides$` — number of sides on the die (default 20 for d20).
148/// - `ResultSubAbilities$` — comma-separated list of "threshold:SVar" pairs.
149///   e.g. "1:Low,10:Mid,20:High" means 1-9→Low, 10-19→Mid, 20→High.
150///
151/// # Card script examples
152/// ```text
153/// A:SP$ RollDice | Sides$ 20 | ResultSubAbilities$ 1:Low,10:Mid,20:High
154/// A:SP$ RollDice | Sides$ 6 | ResultSubAbilities$ 1:Fail,4:Success
155/// ```
156/// Struct form of this effect so it can participate in the
157/// `SpellAbilityEffect` trait hierarchy — mirrors Java's
158/// `RollDiceEffect` class extending `SpellAbilityEffect`.
159#[manabrew_engine_macros::spell_effect(RollDiceEffect)]
160fn resolve(ctx: &mut EffectContext, sa: &crate::spellability::SpellAbility) {
161    let controller = sa.activating_player;
162    let source_id = match sa.source {
163        Some(id) => id,
164        None => return,
165    };
166    let sides = sa.ir.sides.unwrap_or(6);
167    let amount = resolve_numeric_svar(ctx.game, sa, "Amount", 1).max(0);
168    let players = if let Some(player) = sa.target_chosen.target_player {
169        vec![player]
170    } else if let Some(defined) = sa.defined() {
171        let resolved = resolve_defined_players(defined, controller, ctx.game);
172        if resolved.is_empty() {
173            vec![controller]
174        } else {
175            resolved
176        }
177    } else {
178        vec![controller]
179    };
180
181    let mut results = Vec::new();
182    for player in players.iter().copied() {
183        let final_result = roll_for_player(ctx, sa, source_id, player, sides, amount);
184        if sa.param_is_true("ToVisitYourAttractions") {
185            visit_attractions(ctx.game, ctx.trigger_handler, player, final_result);
186        }
187        results.push((player, final_result));
188    }
189
190    if sa.param_is_true("RememberHighestPlayer") {
191        if let Some(highest) = results.iter().map(|(_, result)| *result).max() {
192            for (player, result) in results {
193                if result == highest {
194                    ctx.game.card_mut(source_id).add_remembered_player(player);
195                }
196            }
197        }
198    }
199}
200
201/// One die's natural roll + any modifications applied after the fact.
202/// Mirrors Java's `RollDiceEffect.DieRollResult`.
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204pub struct DieRollResult {
205    pub natural_value: i32,
206    pub modified_value: i32,
207}
208
209fn roll_for_player(
210    ctx: &mut EffectContext,
211    sa: &SpellAbility,
212    source_id: crate::ids::CardId,
213    player: PlayerId,
214    sides: i32,
215    amount: i32,
216) -> i32 {
217    let modifier = resolve_numeric_svar(ctx.game, sa, "Modifier", 0);
218    let rolled_to_visit_attractions = sa.param_is_true("ToVisitYourAttractions");
219    let ignore = resolve_numeric_svar(ctx.game, sa, "IgnoreLower", 0);
220    let mut ignored_rolls = Vec::new();
221    let mut dice_pt_exchanges = HashSet::new();
222    let source_name = ctx.game.card(source_id).card_name.clone();
223    let mut natural_rolls = roll_action(
224        ctx.game,
225        ctx.rng,
226        ctx.agents,
227        player,
228        sides,
229        amount,
230        ignore,
231        &mut ignored_rolls,
232        &mut dice_pt_exchanges,
233        Some(source_id),
234    );
235
236    if sa.param_is_true("UseHighestRoll") && natural_rolls.len() > 1 {
237        let highest = natural_rolls.last().copied().unwrap_or(0);
238        natural_rolls.clear();
239        natural_rolls.push(highest);
240    }
241
242    apply_keyword_roll_rerolls(
243        ctx,
244        sa,
245        player,
246        sides,
247        &mut natural_rolls,
248        &mut ignored_rolls,
249        &mut dice_pt_exchanges,
250    );
251
252    let mut results_list = apply_keyword_roll_modifiers(ctx, sa, player, &mut natural_rolls);
253    for unmodified in natural_rolls {
254        results_list.push(DieRollResult {
255            natural_value: unmodified,
256            modified_value: unmodified,
257        });
258    }
259
260    apply_dice_pt_exchanges(
261        ctx.agents,
262        ctx.game,
263        player,
264        &mut results_list,
265        &dice_pt_exchanges,
266    );
267
268    let kept_natural_rolls: Vec<i32> = results_list.iter().map(|roll| roll.natural_value).collect();
269    let kept_rolls: Vec<i32> = results_list
270        .iter()
271        .map(|roll| roll.modified_value + modifier)
272        .collect();
273
274    let final_result = if sa.param_is_true("UseDifferenceBetweenRolls") && kept_rolls.len() >= 2 {
275        kept_rolls.last().copied().unwrap_or(0) - kept_rolls.first().copied().unwrap_or(0)
276    } else if sa.param_is_true("UseHighestRoll") {
277        kept_rolls.iter().copied().max().unwrap_or(0)
278    } else {
279        kept_rolls.iter().sum()
280    };
281
282    if let Some(result_svar) = sa.ir.result_svar_text.as_deref() {
283        ctx.game
284            .card_mut(source_id)
285            .set_s_var(result_svar, format!("Number${final_result}"));
286    }
287    if sa.param_is_true("StoreResults") {
288        for roll in &kept_rolls {
289            ctx.game.card_mut(source_id).add_stored_rolls(*roll);
290        }
291    }
292    if sa.param_is_true("EvenOddResults") {
293        let even = kept_rolls.iter().filter(|roll| **roll % 2 == 0).count();
294        let odd = kept_rolls.len().saturating_sub(even);
295        let source = ctx.game.card_mut(source_id);
296        source.set_s_var("EvenResults", format!("Number${even}"));
297        source.set_s_var("OddResults", format!("Number${odd}"));
298    }
299    if sa.param_is_true("DifferentResults") {
300        let mut distinct = kept_rolls.clone();
301        distinct.sort();
302        distinct.dedup();
303        ctx.game
304            .card_mut(source_id)
305            .set_s_var("DifferentResults", format!("Number${}", distinct.len()));
306    }
307    if sa.param_is_true("MaxRollsResults") {
308        let max_rolls = kept_natural_rolls
309            .iter()
310            .filter(|roll| **roll == sides)
311            .count();
312        ctx.game
313            .card_mut(source_id)
314            .set_s_var("MaxRolls", format!("Number${max_rolls}"));
315    }
316    if let Some(chosen_svar) = sa.ir.chosen_svar_text.as_deref() {
317        if !kept_rolls.is_empty() {
318            let chosen = ctx.agents[player.index()]
319                .choose_number_from_list(player, &kept_rolls, "Choose a result", Some(source_id))
320                .unwrap_or(kept_rolls[0]);
321            ctx.game
322                .card_mut(source_id)
323                .set_s_var(chosen_svar, format!("Number${chosen}"));
324            if let Some(other_svar) = sa.ir.other_svar_text.as_deref() {
325                let other = kept_rolls
326                    .iter()
327                    .copied()
328                    .find(|roll| *roll != chosen)
329                    .unwrap_or(chosen);
330                ctx.game
331                    .card_mut(source_id)
332                    .set_s_var(other_svar, format!("Number${other}"));
333            }
334        }
335    }
336    if sa.param_is_true("NoteDoubles") {
337        let mut unique = std::collections::HashSet::new();
338        if kept_rolls.iter().any(|roll| !unique.insert(*roll)) {
339            ctx.game.card_mut(source_id).set_s_var("Doubles", "1");
340        }
341    }
342
343    let roll_start_number = ctx.game.player(player).num_rolls_this_turn;
344    let roll_number_base = roll_start_number - amount;
345    for (idx, roll) in results_list.iter().enumerate() {
346        let result = roll.modified_value + modifier;
347        ctx.trigger_handler.run_trigger(
348            TriggerType::RolledDie,
349            RunParams {
350                player: Some(player),
351                die_result: Some(result),
352                natural_result: Some(roll.natural_value),
353                die_sides: Some(sides),
354                number: Some(roll_number_base + idx as i32 + 1),
355                rolled_to_visit_attractions: Some(rolled_to_visit_attractions),
356                ..Default::default()
357            },
358            false,
359        );
360    }
361    ctx.trigger_handler.run_trigger(
362        TriggerType::RolledDieOnce,
363        RunParams {
364            player: Some(player),
365            die_result: Some(final_result),
366            die_results: Some(kept_rolls.clone()),
367            die_sides: Some(sides),
368            rolled_to_visit_attractions: Some(rolled_to_visit_attractions),
369            ..Default::default()
370        },
371        false,
372    );
373
374    // Notify agents of the roll result
375    let rolled_text = kept_rolls
376        .iter()
377        .map(i32::to_string)
378        .collect::<Vec<_>>()
379        .join(", ");
380    crate::agent::notify_all_agents(
381        ctx.agents,
382        GameLogEvent::rule(format!("Rolled {} (d{})", rolled_text, sides)).with_player(player),
383    );
384    if !ignored_rolls.is_empty() {
385        let ignored_text = ignored_rolls
386            .iter()
387            .map(i32::to_string)
388            .collect::<Vec<_>>()
389            .join(", ");
390        crate::agent::notify_all_agents(
391            ctx.agents,
392            GameLogEvent::rule(format!("Ignored rolls: {ignored_text}")).with_player(player),
393        );
394    }
395    crate::agent::game_log::broadcast_notification(
396        ctx.agents,
397        GameNotification::DiceRolled {
398            player,
399            sides,
400            natural_results: kept_natural_rolls.clone(),
401            final_results: kept_rolls.clone(),
402            ignored_rolls: ignored_rolls.clone(),
403            source_card_name: Some(source_name.clone()),
404        },
405    );
406    for agent in ctx.agents.iter_mut() {
407        agent.await_display_ack();
408    }
409
410    if sa.param_is_true("SubsForEach") {
411        if let Some(result_str) = sa.ir.result_sub_abilities_text.as_deref() {
412            for roll in &kept_rolls {
413                resolve_result_sub_ability(ctx, sa, source_id, player, *roll, result_str);
414            }
415        }
416    } else if let Some(result_str) = sa.ir.result_sub_abilities_text.as_deref() {
417        resolve_result_sub_ability(ctx, sa, source_id, player, final_result, result_str);
418    }
419
420    if sa.param_is_true("RerollResults") {
421        let stored_rolls = ctx.game.card(source_id).remembered_cmc.clone();
422        let mut replacements = Vec::new();
423        for old_roll in stored_rolls {
424            if ctx.agents[player.index()].confirm_action(
425                player,
426                Some("RerollResult"),
427                &format!("Reroll result {old_roll}?"),
428                &[],
429                Some(source_id),
430                sa.api,
431            ) {
432                let new_roll = roll_for_player(ctx, sa, source_id, player, sides, 1);
433                replacements.push((old_roll, new_roll));
434            }
435        }
436        for (old_roll, new_roll) in replacements {
437            ctx.game
438                .card_mut(source_id)
439                .replace_stored_roll(old_roll, new_roll);
440        }
441    }
442
443    // Parse ResultSubAbilities$ and find the matching threshold
444    final_result
445}
446
447pub fn roll_to_visit_attractions(
448    game: &mut GameState,
449    trigger_handler: &mut TriggerHandler,
450    rng: &mut (impl GameRng + ?Sized),
451    agents: &mut [Box<dyn crate::agent::PlayerAgent>],
452    mana_pools: &mut [crate::mana::ManaPool],
453    player: PlayerId,
454) {
455    let mut event = ReplacementEvent::RollDice {
456        player,
457        sides: 6,
458        number: 1,
459        ignore: 0,
460        ignore_chosen: HashMap::new(),
461        dice_pt_exchanges: HashSet::new(),
462    };
463    let result = apply_replacements(game, &mut event);
464    if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
465        return;
466    }
467
468    let (roll_count, ignore_lowest, ignore_chosen, dice_pt_exchanges) = match event {
469        ReplacementEvent::RollDice {
470            number,
471            ignore,
472            ignore_chosen,
473            dice_pt_exchanges,
474            ..
475        } => (
476            number.max(0),
477            ignore.max(0),
478            ignore_chosen.clone(),
479            dice_pt_exchanges.clone(),
480        ),
481        _ => (1, 0, HashMap::new(), HashSet::new()),
482    };
483    if roll_count == 0 {
484        return;
485    }
486
487    let mut natural_rolls = Vec::new();
488    for _ in 0..roll_count {
489        natural_rolls.push(rng.next_int(6) + 1);
490    }
491    natural_rolls.sort();
492
493    let ignore_count = ignore_lowest.min(natural_rolls.len() as i32) as usize;
494    let ignored_rolls: Vec<i32> = natural_rolls.drain(..ignore_count).collect();
495    let (ignored_by_choice, _) = apply_chosen_ignores(
496        agents,
497        "Attraction roll",
498        &mut natural_rolls,
499        &ignore_chosen,
500    );
501    let mut ignored_rolls = ignored_rolls;
502    ignored_rolls.extend(ignored_by_choice);
503    let mut results_list = apply_keyword_roll_modifiers_for_attraction(
504        game,
505        trigger_handler,
506        agents,
507        mana_pools,
508        player,
509        &mut natural_rolls,
510    );
511    for unmodified in natural_rolls {
512        results_list.push(DieRollResult {
513            natural_value: unmodified,
514            modified_value: unmodified,
515        });
516    }
517    apply_dice_pt_exchanges(agents, game, player, &mut results_list, &dice_pt_exchanges);
518    let kept_rolls: Vec<i32> = results_list
519        .iter()
520        .map(|roll| roll.modified_value)
521        .collect();
522    let final_result: i32 = kept_rolls.iter().sum();
523
524    let roll_start_number = game.player(player).num_rolls_this_turn;
525    let roll_number_base = roll_start_number - 1;
526    for (idx, result) in kept_rolls.iter().copied().enumerate() {
527        trigger_handler.run_trigger(
528            TriggerType::RolledDie,
529            RunParams {
530                player: Some(player),
531                die_result: Some(result),
532                natural_result: Some(results_list[idx].natural_value),
533                die_sides: Some(6),
534                number: Some(roll_number_base + idx as i32 + 1),
535                rolled_to_visit_attractions: Some(true),
536                ..Default::default()
537            },
538            false,
539        );
540    }
541    trigger_handler.run_trigger(
542        TriggerType::RolledDieOnce,
543        RunParams {
544            player: Some(player),
545            die_result: Some(final_result),
546            die_results: Some(kept_rolls.clone()),
547            die_sides: Some(6),
548            rolled_to_visit_attractions: Some(true),
549            ..Default::default()
550        },
551        false,
552    );
553
554    let rolled_text = kept_rolls
555        .iter()
556        .map(i32::to_string)
557        .collect::<Vec<_>>()
558        .join(", ");
559    crate::agent::notify_all_agents(
560        agents,
561        GameLogEvent::rule(format!("Rolled {} (d6)", rolled_text)).with_player(player),
562    );
563    if !ignored_rolls.is_empty() {
564        let ignored_text = ignored_rolls
565            .iter()
566            .map(i32::to_string)
567            .collect::<Vec<_>>()
568            .join(", ");
569        crate::agent::notify_all_agents(
570            agents,
571            GameLogEvent::rule(format!("Ignored rolls: {ignored_text}")).with_player(player),
572        );
573    }
574    let natural_results: Vec<i32> = results_list.iter().map(|r| r.natural_value).collect();
575    crate::agent::game_log::broadcast_notification(
576        agents,
577        GameNotification::DiceRolled {
578            player,
579            sides: 6,
580            natural_results,
581            final_results: kept_rolls.clone(),
582            ignored_rolls: ignored_rolls.clone(),
583            source_card_name: Some("Attraction roll".to_string()),
584        },
585    );
586    for agent in agents.iter_mut() {
587        agent.await_display_ack();
588    }
589
590    visit_attractions(game, trigger_handler, player, final_result);
591}
592
593fn apply_chosen_ignores(
594    agents: &mut [Box<dyn crate::agent::PlayerAgent>],
595    card_name: &str,
596    natural_rolls: &mut Vec<i32>,
597    ignore_chosen: &HashMap<PlayerId, i32>,
598) -> (Vec<i32>, Vec<i32>) {
599    let mut ignored = Vec::new();
600    for (&chooser, &count) in ignore_chosen {
601        for _ in 0..count.max(0) {
602            if natural_rolls.is_empty() {
603                break;
604            }
605            let choice = agents[chooser.index()]
606                .choose_roll_to_ignore(chooser, natural_rolls, None)
607                .unwrap_or(natural_rolls[0]);
608            let position = natural_rolls
609                .iter()
610                .position(|roll| *roll == choice)
611                .unwrap_or(0);
612            ignored.push(natural_rolls.remove(position));
613        }
614    }
615    (ignored, natural_rolls.clone())
616}
617
618fn roll_action(
619    game: &mut GameState,
620    rng: &mut (impl GameRng + ?Sized),
621    agents: &mut [Box<dyn crate::agent::PlayerAgent>],
622    player: PlayerId,
623    sides: i32,
624    amount: i32,
625    ignore: i32,
626    ignored_rolls: &mut Vec<i32>,
627    dice_pt_exchanges: &mut HashSet<crate::ids::CardId>,
628    source: Option<crate::ids::CardId>,
629) -> Vec<i32> {
630    let mut event = ReplacementEvent::RollDice {
631        player,
632        sides,
633        number: amount,
634        ignore,
635        ignore_chosen: HashMap::new(),
636        dice_pt_exchanges: HashSet::new(),
637    };
638    let result = apply_replacements(game, &mut event);
639    if result == ReplacementResult::Skipped || result == ReplacementResult::Replaced {
640        return Vec::new();
641    }
642
643    let (roll_count, ignore_lowest, ignore_chosen, new_pt_exchanges) = match event {
644        ReplacementEvent::RollDice {
645            number,
646            ignore,
647            ignore_chosen,
648            dice_pt_exchanges,
649            ..
650        } => (
651            number.max(0),
652            ignore.max(0),
653            ignore_chosen,
654            dice_pt_exchanges,
655        ),
656        _ => (amount.max(0), ignore.max(0), HashMap::new(), HashSet::new()),
657    };
658    dice_pt_exchanges.extend(new_pt_exchanges);
659
660    let mut natural_rolls = Vec::new();
661    for _ in 0..roll_count {
662        natural_rolls.push(rng.next_int(sides) + 1);
663        game.player_record_roll(player, None);
664    }
665    natural_rolls.sort();
666
667    let ignore_count = ignore_lowest.min(natural_rolls.len() as i32) as usize;
668    ignored_rolls.extend(natural_rolls.drain(..ignore_count));
669
670    let (ignored_by_choice, _) =
671        apply_chosen_ignores(agents, "Roll", &mut natural_rolls, &ignore_chosen);
672    ignored_rolls.extend(ignored_by_choice);
673    natural_rolls
674}
675
676fn apply_dice_pt_exchanges(
677    agents: &mut [Box<dyn crate::agent::PlayerAgent>],
678    game: &mut GameState,
679    player: PlayerId,
680    results_list: &mut [DieRollResult],
681    dice_pt_exchanges: &HashSet<crate::ids::CardId>,
682) {
683    let mut available_cards: Vec<_> = dice_pt_exchanges.iter().copied().collect();
684    while !available_cards.is_empty() && !results_list.is_empty() {
685        let current_rolls: Vec<i32> = results_list
686            .iter()
687            .map(|roll| roll.modified_value)
688            .collect();
689        let roll = agents[player.index()].choose_roll_to_swap(player, &current_rolls, None);
690        let Some(roll_value) = roll else { break };
691        let Some(roll_index) = results_list
692            .iter()
693            .position(|roll| roll.modified_value == roll_value)
694        else {
695            break;
696        };
697        let card_id = available_cards.remove(0);
698        let current_power = game.card(card_id).power();
699        let current_toughness = game.card(card_id).toughness();
700        let choice = agents[player.index()].choose_roll_swap_value(
701            player,
702            roll_value,
703            current_power,
704            current_toughness,
705            Some(card_id),
706        );
707        let Some(choice) = choice else { continue };
708        match choice {
709            crate::agent::RollSwapChoice::Power => {
710                results_list[roll_index].modified_value = current_power;
711                game.card_mut(card_id)
712                    .add_new_pt(roll_value, current_toughness);
713            }
714            crate::agent::RollSwapChoice::Toughness => {
715                results_list[roll_index].modified_value = current_toughness;
716                game.card_mut(card_id).add_new_pt(current_power, roll_value);
717            }
718        }
719    }
720}
721
722fn apply_keyword_roll_modifiers(
723    ctx: &mut EffectContext,
724    sa: &SpellAbility,
725    player: PlayerId,
726    natural_rolls: &mut Vec<i32>,
727) -> Vec<DieRollResult> {
728    apply_simple_roll_modifiers(
729        ctx.game,
730        ctx.trigger_handler,
731        ctx.agents,
732        ctx.mana_pools,
733        player,
734        natural_rolls,
735        Some(sa),
736    )
737}
738
739fn apply_keyword_roll_modifiers_for_attraction(
740    game: &mut GameState,
741    trigger_handler: &mut TriggerHandler,
742    agents: &mut [Box<dyn crate::agent::PlayerAgent>],
743    mana_pools: &mut [crate::mana::ManaPool],
744    player: PlayerId,
745    natural_rolls: &mut Vec<i32>,
746) -> Vec<DieRollResult> {
747    apply_simple_roll_modifiers(
748        game,
749        trigger_handler,
750        agents,
751        mana_pools,
752        player,
753        natural_rolls,
754        None,
755    )
756}
757
758fn apply_simple_roll_modifiers(
759    game: &mut GameState,
760    trigger_handler: &mut TriggerHandler,
761    agents: &mut [Box<dyn crate::agent::PlayerAgent>],
762    mana_pools: &mut [crate::mana::ManaPool],
763    player: PlayerId,
764    natural_rolls: &mut Vec<i32>,
765    source_sa: Option<&SpellAbility>,
766) -> Vec<DieRollResult> {
767    const XENO_KEYWORD: &str =
768        "After you roll a die, you may remove a +1/+1 counter from Xenosquirrels. If you do, increase or decrease the result by 1.";
769    const NIGHT_SHIFT_KEYWORD: &str =
770        "After you roll a die, you may pay 1 life. If you do, increase or decrease the result by 1. Do this only once each turn.";
771    let mut results_list = Vec::new();
772
773    loop {
774        if natural_rolls.is_empty() {
775            break;
776        }
777
778        let modifiers: Vec<_> = game
779            .cards_in_zone(forge_foundation::ZoneType::Battlefield, player)
780            .iter()
781            .copied()
782            .filter(|&card_id| {
783                can_modify_roll(game, player, card_id, XENO_KEYWORD, NIGHT_SHIFT_KEYWORD)
784            })
785            .collect();
786        if modifiers.is_empty() {
787            break;
788        }
789
790        let roll = agents[player.index()].choose_roll_to_modify(player, natural_rolls, None);
791        let Some(mut roll_value) = roll else { break };
792        let Some(roll_index) = natural_rolls.iter().position(|value| *value == roll_value) else {
793            break;
794        };
795
796        let mut available = modifiers;
797        let mut modified = false;
798        let natural_value = roll_value;
799        while !available.is_empty() {
800            let chosen = agents[player.index()].choose_single_card_for_zone_change(
801                game,
802                player,
803                &available,
804                "Choose a roll modifier",
805                true,
806            );
807            let Some(card_id) = chosen else { break };
808            let roll_modify_cost = game
809                .card(card_id)
810                .svars
811                .get("RollModifyCost")
812                .cloned()
813                .unwrap_or_default();
814            let can_pay = pay_roll_cost(
815                game,
816                trigger_handler,
817                agents,
818                mana_pools,
819                player,
820                card_id,
821                &roll_modify_cost,
822                source_sa,
823            );
824            available.retain(|candidate| *candidate != card_id);
825            if !can_pay {
826                continue;
827            }
828            let increase = agents[player.index()].choose_binary(
829                player,
830                "Increase or decrease the roll?",
831                crate::agent::BinaryChoiceKind::IncreaseOrDecrease,
832                Some(true),
833                Some(card_id),
834                source_sa.and_then(|sa| sa.api),
835            );
836            roll_value += if increase { 1 } else { -1 };
837            increment_roll_modifications(game, card_id);
838            modified = true;
839        }
840
841        if modified {
842            natural_rolls.remove(roll_index);
843            results_list.push(DieRollResult {
844                natural_value,
845                modified_value: roll_value,
846            });
847        } else {
848            break;
849        }
850    }
851    results_list
852}
853
854fn can_modify_roll(
855    game: &GameState,
856    player: PlayerId,
857    card_id: crate::ids::CardId,
858    xeno_keyword: &str,
859    night_shift_keyword: &str,
860) -> bool {
861    let card = game.card(card_id);
862    if card.has_keyword(xeno_keyword) {
863        return card.counter_count(&crate::card::CounterType::P1P1) > 0;
864    }
865    if card.has_keyword(night_shift_keyword) {
866        let limit = card
867            .svars
868            .get("RollModificationsLimit")
869            .map(|value| value.trim())
870            .unwrap_or("None");
871        let used = roll_modifications_used(card);
872        let under_limit = limit.eq_ignore_ascii_case("None")
873            || limit.parse::<i32>().ok().is_some_and(|max| used < max);
874        return under_limit
875            && game.player(player).life > 1
876            && !crate::staticability::static_ability_cant_gain_lose_pay_life::cant_pay_life(
877                game, player, true, None,
878            );
879    }
880    false
881}
882
883fn roll_modifications_used(card: &crate::card::Card) -> i32 {
884    card.svars
885        .get("ModsThisTurn")
886        .and_then(|value| value.strip_prefix("Number$"))
887        .and_then(|value| value.parse::<i32>().ok())
888        .unwrap_or(0)
889}
890
891fn increment_roll_modifications(game: &mut GameState, card_id: crate::ids::CardId) {
892    let used = roll_modifications_used(game.card(card_id)) + 1;
893    game.card_mut(card_id)
894        .set_s_var("ModsThisTurn", format!("Number${used}"));
895}
896
897fn can_pay_roll_cost(
898    game: &GameState,
899    mana_pools: &[crate::mana::ManaPool],
900    player: PlayerId,
901    card_id: crate::ids::CardId,
902    cost: &Cost,
903    source_sa: Option<&SpellAbility>,
904) -> bool {
905    let available = mana::calculate_available_mana(&mana_pools[player.index()], game, player);
906    crate::cost::can_pay_with_ability(cost, game, &available, card_id, player, source_sa)
907}
908
909fn pay_roll_cost(
910    game: &mut GameState,
911    trigger_handler: &mut TriggerHandler,
912    agents: &mut [Box<dyn crate::agent::PlayerAgent>],
913    mana_pools: &mut [crate::mana::ManaPool],
914    player: PlayerId,
915    card_id: crate::ids::CardId,
916    cost_raw: &str,
917    source_sa: Option<&SpellAbility>,
918) -> bool {
919    let cost = parse_cost(cost_raw);
920    if !can_pay_roll_cost(game, mana_pools, player, card_id, &cost, source_sa) {
921        return false;
922    }
923
924    for part in &cost.parts {
925        let should_ask = matches!(
926            part,
927            CostPart::DamageYou(_)
928                | CostPart::PayLife(_)
929                | CostPart::Draw(_)
930                | CostPart::Mill(_)
931                | CostPart::AddMana { .. }
932                | CostPart::Discard { .. }
933                | CostPart::Sacrifice { .. }
934        );
935        if should_ask
936            && !agents[player.index()].confirm_payment(
937                player,
938                match part {
939                    CostPart::DamageYou(_) => "DamageYou",
940                    CostPart::PayLife(_) => "PayLife",
941                    CostPart::Draw(_) => "Draw",
942                    CostPart::Mill(_) => "Mill",
943                    CostPart::AddMana { .. } => "AddMana",
944                    CostPart::Discard { .. } => "Discard",
945                    CostPart::Sacrifice { .. } => "Sacrifice",
946                    _ => "Cost",
947                },
948                "Pay roll modification cost?",
949                Some(card_id),
950                source_sa.and_then(|sa| sa.api),
951            )
952        {
953            return false;
954        }
955    }
956
957    for part in &cost.parts {
958        match part {
959            CostPart::Mana {
960                cost: mana_cost, ..
961            } => {
962                let game_ptr: *mut GameState = game;
963                let trigger_handler_ptr = std::ptr::from_mut(trigger_handler);
964                let mut callback = |kind: mana::ManaPayCallback<'_>| -> Option<crate::ids::CardId> {
965                    match kind {
966                        mana::ManaPayCallback::ChooseSacrifice(valid) => {
967                            agents[player.index()].choose_sacrifice(player, valid, Some(card_id))
968                        }
969                        mana::ManaPayCallback::ChooseColor(valid_colors) => {
970                            // Always invoke the agent — humans see an
971                            // interactive `ChooseColor` modal, AI
972                            // returns a default. No engine-side branch.
973                            let _ = agents[player.index()].choose_color(player, valid_colors);
974                            None
975                        }
976                        mana::ManaPayCallback::ChooseTapType { .. } => None,
977                        mana::ManaPayCallback::ConfirmSelfSacrifice(sacrifice_id) => {
978                            if agents[player.index()].confirm_payment(
979                                player,
980                                "Sacrifice",
981                                "Sacrifice for mana",
982                                Some(sacrifice_id),
983                                Some(crate::ability::api_type::ApiType::Mana),
984                            ) {
985                                Some(sacrifice_id)
986                            } else {
987                                None
988                            }
989                        }
990                        mana::ManaPayCallback::ConfirmSubCounter(source_id) => {
991                            if agents[player.index()].confirm_payment(
992                                player,
993                                "SubCounter",
994                                "Remove counter for mana",
995                                Some(source_id),
996                                Some(crate::ability::api_type::ApiType::Mana),
997                            ) {
998                                Some(source_id)
999                            } else {
1000                                None
1001                            }
1002                        }
1003                        mana::ManaPayCallback::ConfirmSourceExile(source_id) => {
1004                            if agents[player.index()].confirm_payment(
1005                                player,
1006                                "Exile",
1007                                "Exile for mana",
1008                                Some(source_id),
1009                                Some(crate::ability::api_type::ApiType::Mana),
1010                            ) {
1011                                Some(source_id)
1012                            } else {
1013                                None
1014                            }
1015                        }
1016                        mana::ManaPayCallback::ConfirmPayLife(source_id) => {
1017                            if agents[player.index()].confirm_payment(
1018                                player,
1019                                "PayLife",
1020                                "Pay life for mana",
1021                                Some(source_id),
1022                                Some(crate::ability::api_type::ApiType::Mana),
1023                            ) {
1024                                Some(source_id)
1025                            } else {
1026                                None
1027                            }
1028                        }
1029                        mana::ManaPayCallback::NotifySacrificeForMana(sacrificed_id) => unsafe {
1030                            let game = &mut *game_ptr;
1031                            let trigger_handler = &mut *trigger_handler_ptr;
1032                            let owner = game.card(sacrificed_id).owner;
1033                            let controller = game.card(sacrificed_id).controller;
1034                            let lki_counters = game.card(sacrificed_id).counters.clone();
1035                            let lki_power = game.card(sacrificed_id).power();
1036                            let lki_toughness = game.card(sacrificed_id).toughness();
1037                            let lki_p1p1 = *lki_counters
1038                                .get(&crate::card::CounterType::P1P1)
1039                                .unwrap_or(&0);
1040                            {
1041                                let card = game.card_mut(sacrificed_id);
1042                                card.lki_counters = Some(lki_counters);
1043                                card.set_lki_power_toughness(Some(lki_power), Some(lki_toughness));
1044                            }
1045                            game.last_sacrificed_card = Some(sacrificed_id);
1046                            trigger_handler.run_trigger(
1047                                TriggerType::Sacrificed,
1048                                RunParams {
1049                                    card: Some(sacrificed_id),
1050                                    player: Some(controller),
1051                                    ..Default::default()
1052                                },
1053                                false,
1054                            );
1055                            crate::ability::effects::emit_zone_trigger_with_lki_counters(
1056                                trigger_handler,
1057                                sacrificed_id,
1058                                ZoneType::Battlefield,
1059                                ZoneType::Graveyard,
1060                                lki_p1p1,
1061                                lki_power,
1062                                lki_toughness,
1063                            );
1064                            trigger_handler.flush_waiting_triggers(game);
1065                            game.move_card(sacrificed_id, ZoneType::Graveyard, owner);
1066                            let mut by_controller = std::collections::BTreeMap::new();
1067                            by_controller.insert(controller, vec![sacrificed_id]);
1068                            crate::game_loop::fire_sacrificed_once_for_batch(
1069                                game,
1070                                trigger_handler,
1071                                &by_controller,
1072                            );
1073                            Some(sacrificed_id)
1074                        },
1075                        mana::ManaPayCallback::ApplyProduceManaReplacement {
1076                            activator,
1077                            source_card,
1078                            mana,
1079                        } => unsafe {
1080                            let game = &mut *game_ptr;
1081                            let mut event =
1082                                crate::replacement::replacement_handler::ReplacementEvent::ProduceMana {
1083                                    source: source_card,
1084                                    activator,
1085                                    mana: mana.clone(),
1086                                };
1087                            let result =
1088                                crate::replacement::replacement_handler::apply_replacements_with_agents(
1089                                    game, agents, &mut event,
1090                                );
1091                            if result == crate::replacement::ReplacementResult::Updated {
1092                                if let crate::replacement::replacement_handler::ReplacementEvent::ProduceMana {
1093                                    mana: new_mana,
1094                                    ..
1095                                } = event
1096                                {
1097                                    *mana = new_mana;
1098                                }
1099                            }
1100                            None
1101                        },
1102                    }
1103                };
1104                let tapped = mana::auto_tap_lands_with_callbacks(
1105                    game,
1106                    &mut mana_pools[player.index()],
1107                    player,
1108                    mana_cost,
1109                    Some(card_id),
1110                    &mut callback,
1111                );
1112                for &land_id in &tapped {
1113                    trigger_handler.run_trigger(
1114                        TriggerType::Taps,
1115                        RunParams {
1116                            card: Some(land_id),
1117                            player: Some(player),
1118                            ..Default::default()
1119                        },
1120                        false,
1121                    );
1122                    trigger_handler.run_trigger(
1123                        TriggerType::TapsForMana,
1124                        RunParams {
1125                            card: Some(land_id),
1126                            player: Some(player),
1127                            ..Default::default()
1128                        },
1129                        false,
1130                    );
1131                }
1132                if !mana_pools[player.index()].try_pay(mana_cost) {
1133                    return false;
1134                }
1135            }
1136            CostPart::PayLife(amount) => {
1137                game.player_lose_life(player, amount.resolve(game, card_id, player));
1138                trigger_handler.run_trigger(
1139                    TriggerType::LifeLost,
1140                    RunParams {
1141                        player: Some(player),
1142                        life_amount: Some(amount.resolve(game, card_id, player)),
1143                        first_time: Some(false),
1144                        ..Default::default()
1145                    },
1146                    false,
1147                );
1148            }
1149            CostPart::SubCounter {
1150                amount,
1151                counter_type,
1152                ..
1153            } => {
1154                let amount_n = amount.resolve(game, card_id, player);
1155                if game.card(card_id).counter_count(counter_type) < amount_n {
1156                    return false;
1157                }
1158                game.card_mut(card_id)
1159                    .remove_counter(counter_type, amount_n);
1160            }
1161            _ => return false,
1162        }
1163    }
1164
1165    true
1166}
1167
1168fn apply_keyword_roll_rerolls(
1169    ctx: &mut EffectContext,
1170    sa: &SpellAbility,
1171    player: PlayerId,
1172    sides: i32,
1173    natural_rolls: &mut Vec<i32>,
1174    ignored_rolls: &mut Vec<i32>,
1175    dice_pt_exchanges: &mut HashSet<crate::ids::CardId>,
1176) {
1177    const MONITOR_KEYWORD: &str =
1178        "Once each turn, you may pay {1} to reroll one or more dice you rolled.";
1179
1180    loop {
1181        let reroll_cards = get_reroll_cards(ctx.game, player, MONITOR_KEYWORD);
1182        if reroll_cards.is_empty() || natural_rolls.is_empty() {
1183            break;
1184        }
1185
1186        let dice_to_reroll = ctx.agents[player.index()].choose_dice_to_reroll(
1187            player,
1188            natural_rolls,
1189            Some(sa.source.unwrap_or(reroll_cards[0])),
1190        );
1191        if dice_to_reroll.is_empty() {
1192            break;
1193        }
1194
1195        let chosen = ctx.agents[player.index()].choose_single_card_for_zone_change(
1196            ctx.game,
1197            player,
1198            &reroll_cards,
1199            "Choose a card to reroll dice",
1200            true,
1201        );
1202        let Some(card_id) = chosen else { break };
1203        let reroll_cost = ctx
1204            .game
1205            .card(card_id)
1206            .svars
1207            .get("RollRerollCost")
1208            .cloned()
1209            .unwrap_or_default();
1210        if !pay_roll_cost(
1211            ctx.game,
1212            ctx.trigger_handler,
1213            ctx.agents,
1214            ctx.mana_pools,
1215            player,
1216            card_id,
1217            &reroll_cost,
1218            Some(sa),
1219        ) {
1220            continue;
1221        }
1222
1223        for reroll in &dice_to_reroll {
1224            if let Some(position) = natural_rolls.iter().position(|roll| roll == reroll) {
1225                natural_rolls.remove(position);
1226            }
1227        }
1228
1229        let reroll_card_name = ctx.game.card(card_id).card_name.clone();
1230        let rerolled = roll_action(
1231            ctx.game,
1232            ctx.rng,
1233            ctx.agents,
1234            player,
1235            sides,
1236            dice_to_reroll.len() as i32,
1237            0,
1238            ignored_rolls,
1239            dice_pt_exchanges,
1240            sa.source,
1241        );
1242        natural_rolls.extend(rerolled);
1243        natural_rolls.sort();
1244        increment_roll_modifications(ctx.game, card_id);
1245    }
1246}
1247
1248/// Return the battlefield cards under `player` that can currently reroll dice.
1249/// Mirrors Java `RollDiceEffect.getRerollCards`.
1250pub fn get_reroll_cards(
1251    game: &GameState,
1252    player: PlayerId,
1253    monitor_keyword: &str,
1254) -> Vec<crate::ids::CardId> {
1255    game.cards_in_zone(forge_foundation::ZoneType::Battlefield, player)
1256        .iter()
1257        .copied()
1258        .filter(|&card_id| {
1259            let card = game.card(card_id);
1260            if !card.has_keyword(monitor_keyword) {
1261                return false;
1262            }
1263            let limit = card
1264                .svars
1265                .get("RollModificationsLimit")
1266                .map(String::as_str)
1267                .unwrap_or("None");
1268            let used = roll_modifications_used(card);
1269            limit.eq_ignore_ascii_case("None")
1270                || limit.parse::<i32>().ok().is_some_and(|max| used < max)
1271        })
1272        .collect()
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277    use super::*;
1278    use crate::agent::PlayerAgent;
1279    use crate::card::Card;
1280    use crate::game_rng::GameRng;
1281    use crate::ids::{CardId, PlayerId};
1282    use crate::mana::ManaPool;
1283    use crate::spellability::SpellAbility;
1284    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
1285    use std::collections::VecDeque;
1286
1287    struct FixedRng {
1288        values: VecDeque<i32>,
1289    }
1290
1291    impl FixedRng {
1292        fn new(values: &[i32]) -> Self {
1293            Self {
1294                values: values.iter().copied().collect(),
1295            }
1296        }
1297    }
1298
1299    impl GameRng for FixedRng {
1300        fn shuffle_cards(&mut self, _cards: &mut [CardId]) {}
1301
1302        fn next_int(&mut self, bound: i32) -> i32 {
1303            let value = self.values.pop_front().unwrap_or(0);
1304            assert!(
1305                value >= 0 && value < bound,
1306                "fixed rng value {value} out of range for {bound}"
1307            );
1308            value
1309        }
1310    }
1311
1312    struct SwapAgent;
1313
1314    impl PlayerAgent for SwapAgent {
1315        fn mulligan_decision(
1316            &mut self,
1317            _player: PlayerId,
1318            _hand: &[CardId],
1319            _mulligan_count: u32,
1320        ) -> bool {
1321            true
1322        }
1323
1324        fn choose_action(
1325            &mut self,
1326            player: PlayerId,
1327            action_space: Option<&crate::agent::PriorityActionSpace>,
1328            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
1329        ) -> crate::player::actions::PlayerAction {
1330            crate::player::actions::PlayerAction::PassPriority
1331        }
1332
1333        fn choose_attackers(
1334            &mut self,
1335            _player: PlayerId,
1336            _available: &[CardId],
1337            _possible_defenders: &[crate::combat::DefenderId],
1338        ) -> Vec<(CardId, crate::combat::DefenderId)> {
1339            vec![]
1340        }
1341
1342        fn choose_blockers(
1343            &mut self,
1344            _player: PlayerId,
1345            _attackers: &[CardId],
1346            _available_blockers: &[CardId],
1347            _max_blockers: Option<usize>,
1348        ) -> Vec<(CardId, CardId)> {
1349            vec![]
1350        }
1351
1352        fn choose_target_player(
1353            &mut self,
1354            _player: PlayerId,
1355            _valid: &[PlayerId],
1356            _sa: Option<&crate::spellability::SpellAbility>,
1357        ) -> Option<PlayerId> {
1358            None
1359        }
1360
1361        fn choose_target_card(
1362            &mut self,
1363            _player: PlayerId,
1364            _valid: &[CardId],
1365            _sa: Option<&crate::spellability::SpellAbility>,
1366        ) -> Option<CardId> {
1367            None
1368        }
1369
1370        fn choose_target_any(
1371            &mut self,
1372            _player: PlayerId,
1373            _valid_players: &[PlayerId],
1374            _valid_cards: &[CardId],
1375            _sa: Option<&crate::spellability::SpellAbility>,
1376        ) -> crate::agent::TargetChoice {
1377            crate::agent::TargetChoice::None
1378        }
1379
1380        fn choose_roll_to_swap(
1381            &mut self,
1382            _player: PlayerId,
1383            rolls: &[i32],
1384            _source: Option<crate::ids::CardId>,
1385        ) -> Option<i32> {
1386            rolls.first().copied()
1387        }
1388
1389        fn choose_roll_swap_value(
1390            &mut self,
1391            _player: PlayerId,
1392            _current_result: i32,
1393            _power: i32,
1394            _toughness: i32,
1395            _source: Option<crate::ids::CardId>,
1396        ) -> Option<crate::agent::RollSwapChoice> {
1397            Some(crate::agent::RollSwapChoice::Power)
1398        }
1399
1400        fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
1401            Some(true)
1402        }
1403
1404        fn choose_targets_for(
1405            &mut self,
1406            _sa: &mut SpellAbility,
1407            _game: &GameState,
1408            _mana_pools: &[ManaPool],
1409        ) -> bool {
1410            false
1411        }
1412    }
1413
1414    struct ModifyAgent;
1415
1416    impl PlayerAgent for ModifyAgent {
1417        fn mulligan_decision(
1418            &mut self,
1419            _player: PlayerId,
1420            _hand: &[CardId],
1421            _mulligan_count: u32,
1422        ) -> bool {
1423            true
1424        }
1425
1426        fn choose_action(
1427            &mut self,
1428            player: PlayerId,
1429            action_space: Option<&crate::agent::PriorityActionSpace>,
1430            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
1431        ) -> crate::player::actions::PlayerAction {
1432            crate::player::actions::PlayerAction::PassPriority
1433        }
1434
1435        fn choose_attackers(
1436            &mut self,
1437            _player: PlayerId,
1438            _available: &[CardId],
1439            _possible_defenders: &[crate::combat::DefenderId],
1440        ) -> Vec<(CardId, crate::combat::DefenderId)> {
1441            vec![]
1442        }
1443
1444        fn choose_blockers(
1445            &mut self,
1446            _player: PlayerId,
1447            _attackers: &[CardId],
1448            _available_blockers: &[CardId],
1449            _max_blockers: Option<usize>,
1450        ) -> Vec<(CardId, CardId)> {
1451            vec![]
1452        }
1453
1454        fn choose_target_player(
1455            &mut self,
1456            _player: PlayerId,
1457            _valid: &[PlayerId],
1458            _sa: Option<&crate::spellability::SpellAbility>,
1459        ) -> Option<PlayerId> {
1460            None
1461        }
1462
1463        fn choose_target_card(
1464            &mut self,
1465            _player: PlayerId,
1466            _valid: &[CardId],
1467            _sa: Option<&crate::spellability::SpellAbility>,
1468        ) -> Option<CardId> {
1469            None
1470        }
1471
1472        fn choose_target_any(
1473            &mut self,
1474            _player: PlayerId,
1475            _valid_players: &[PlayerId],
1476            _valid_cards: &[CardId],
1477            _sa: Option<&crate::spellability::SpellAbility>,
1478        ) -> crate::agent::TargetChoice {
1479            crate::agent::TargetChoice::None
1480        }
1481
1482        fn choose_roll_to_modify(
1483            &mut self,
1484            _player: PlayerId,
1485            rolls: &[i32],
1486            _source: Option<crate::ids::CardId>,
1487        ) -> Option<i32> {
1488            rolls.first().copied()
1489        }
1490
1491        fn choose_single_card_for_zone_change(
1492            &mut self,
1493            _game: &crate::game::GameState,
1494            _player: PlayerId,
1495            valid: &[CardId],
1496            _select_prompt: &str,
1497            _is_optional: bool,
1498        ) -> Option<CardId> {
1499            valid.first().copied()
1500        }
1501
1502        fn choose_binary(
1503            &mut self,
1504            _player: PlayerId,
1505            _question: &str,
1506            _kind: crate::agent::BinaryChoiceKind,
1507            _default: Option<bool>,
1508            _source: Option<crate::ids::CardId>,
1509            _api: Option<crate::ability::api_type::ApiType>,
1510        ) -> bool {
1511            true
1512        }
1513
1514        fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
1515            Some(true)
1516        }
1517
1518        fn choose_targets_for(
1519            &mut self,
1520            _sa: &mut SpellAbility,
1521            _game: &GameState,
1522            _mana_pools: &[ManaPool],
1523        ) -> bool {
1524            false
1525        }
1526    }
1527
1528    struct RerollAgent;
1529
1530    impl PlayerAgent for RerollAgent {
1531        fn mulligan_decision(
1532            &mut self,
1533            _player: PlayerId,
1534            _hand: &[CardId],
1535            _mulligan_count: u32,
1536        ) -> bool {
1537            true
1538        }
1539
1540        fn choose_action(
1541            &mut self,
1542            player: PlayerId,
1543            action_space: Option<&crate::agent::PriorityActionSpace>,
1544            request_action_space: &mut dyn FnMut() -> crate::agent::PriorityActionSpace,
1545        ) -> crate::player::actions::PlayerAction {
1546            crate::player::actions::PlayerAction::PassPriority
1547        }
1548
1549        fn choose_attackers(
1550            &mut self,
1551            _player: PlayerId,
1552            _available: &[CardId],
1553            _possible_defenders: &[crate::combat::DefenderId],
1554        ) -> Vec<(CardId, crate::combat::DefenderId)> {
1555            vec![]
1556        }
1557
1558        fn choose_blockers(
1559            &mut self,
1560            _player: PlayerId,
1561            _attackers: &[CardId],
1562            _available_blockers: &[CardId],
1563            _max_blockers: Option<usize>,
1564        ) -> Vec<(CardId, CardId)> {
1565            vec![]
1566        }
1567
1568        fn choose_target_player(
1569            &mut self,
1570            _player: PlayerId,
1571            _valid: &[PlayerId],
1572            _sa: Option<&crate::spellability::SpellAbility>,
1573        ) -> Option<PlayerId> {
1574            None
1575        }
1576
1577        fn choose_target_card(
1578            &mut self,
1579            _player: PlayerId,
1580            _valid: &[CardId],
1581            _sa: Option<&crate::spellability::SpellAbility>,
1582        ) -> Option<CardId> {
1583            None
1584        }
1585
1586        fn choose_target_any(
1587            &mut self,
1588            _player: PlayerId,
1589            _valid_players: &[PlayerId],
1590            _valid_cards: &[CardId],
1591            _sa: Option<&crate::spellability::SpellAbility>,
1592        ) -> crate::agent::TargetChoice {
1593            crate::agent::TargetChoice::None
1594        }
1595
1596        fn choose_dice_to_reroll(
1597            &mut self,
1598            _player: PlayerId,
1599            rolls: &[i32],
1600            _source: Option<crate::ids::CardId>,
1601        ) -> Vec<i32> {
1602            rolls.first().copied().into_iter().collect()
1603        }
1604
1605        fn choose_single_card_for_zone_change(
1606            &mut self,
1607            _game: &crate::game::GameState,
1608            _player: PlayerId,
1609            valid: &[CardId],
1610            _select_prompt: &str,
1611            _is_optional: bool,
1612        ) -> Option<CardId> {
1613            valid.first().copied()
1614        }
1615
1616        fn choose_land_or_spell(&mut self, _player: PlayerId) -> Option<bool> {
1617            Some(true)
1618        }
1619
1620        fn choose_targets_for(
1621            &mut self,
1622            _sa: &mut SpellAbility,
1623            _game: &GameState,
1624            _mana_pools: &[ManaPool],
1625        ) -> bool {
1626            false
1627        }
1628    }
1629
1630    #[test]
1631    fn dice_pt_exchange_swaps_roll_with_power() {
1632        let mut game = GameState::new(&["Alice", "Bob"], 20);
1633        let player = PlayerId(0);
1634        let mut card = Card::new(
1635            CardId(0),
1636            "Vedalken Squirrel-Whacker".to_string(),
1637            player,
1638            CardTypeLine::parse("Creature Vedalken"),
1639            ManaCost::parse("3 U"),
1640            ColorSet::BLUE,
1641            Some(2),
1642            Some(5),
1643            vec![],
1644            vec![],
1645        );
1646        card.zone = forge_foundation::ZoneType::Battlefield;
1647        let card_id = game.create_card(card);
1648        game.add_card_to_zone(forge_foundation::ZoneType::Battlefield, player, card_id);
1649
1650        let mut agents: Vec<Box<dyn PlayerAgent>> = vec![Box::new(SwapAgent), Box::new(SwapAgent)];
1651        let mut rolls = vec![
1652            DieRollResult {
1653                natural_value: 4,
1654                modified_value: 4,
1655            },
1656            DieRollResult {
1657                natural_value: 6,
1658                modified_value: 6,
1659            },
1660        ];
1661        let mut swaps = HashSet::new();
1662        swaps.insert(card_id);
1663
1664        apply_dice_pt_exchanges(&mut agents, &mut game, player, &mut rolls, &swaps);
1665
1666        assert_eq!(
1667            rolls
1668                .iter()
1669                .map(|roll| roll.modified_value)
1670                .collect::<Vec<_>>(),
1671            vec![2, 6]
1672        );
1673        assert_eq!(game.card(card_id).base_power, Some(4));
1674        assert_eq!(game.card(card_id).base_toughness, Some(5));
1675    }
1676
1677    #[test]
1678    fn xenosquirrels_can_increase_roll_by_removing_counter() {
1679        let mut game = GameState::new(&["Alice", "Bob"], 20);
1680        let player = PlayerId(0);
1681        let mut card = Card::new(
1682            CardId(0),
1683            "Xenosquirrels".to_string(),
1684            player,
1685            CardTypeLine::parse("Creature Alien Squirrel"),
1686            ManaCost::parse("1 B"),
1687            ColorSet::BLACK,
1688            Some(0),
1689            Some(0),
1690            vec!["After you roll a die, you may remove a +1/+1 counter from Xenosquirrels. If you do, increase or decrease the result by 1.".to_string()],
1691            vec![],
1692        );
1693        card.set_s_var("RollModifyCost", "SubCounter<1/P1P1>");
1694        card.set_s_var("RollModificationsLimit", "None");
1695        card.set_s_var("ModsThisTurn", "Number$0");
1696        card.add_counter(&crate::card::CounterType::P1P1, 2);
1697        card.zone = forge_foundation::ZoneType::Battlefield;
1698        let card_id = game.create_card(card);
1699        game.add_card_to_zone(forge_foundation::ZoneType::Battlefield, player, card_id);
1700
1701        let mut agents: Vec<Box<dyn PlayerAgent>> =
1702            vec![Box::new(ModifyAgent), Box::new(ModifyAgent)];
1703        let mut trigger_handler = TriggerHandler::new();
1704        let mut mana_pools = vec![ManaPool::default(), ManaPool::default()];
1705        let mut rolls = vec![4];
1706
1707        let results = apply_simple_roll_modifiers(
1708            &mut game,
1709            &mut trigger_handler,
1710            &mut agents,
1711            &mut mana_pools,
1712            player,
1713            &mut rolls,
1714            None,
1715        );
1716
1717        assert_eq!(
1718            results
1719                .iter()
1720                .map(|roll| roll.modified_value)
1721                .collect::<Vec<_>>(),
1722            vec![5]
1723        );
1724        assert_eq!(
1725            game.card(card_id)
1726                .counter_count(&crate::card::CounterType::P1P1),
1727            1
1728        );
1729        assert_eq!(
1730            game.card(card_id).svars.get("ModsThisTurn"),
1731            Some(&"Number$1".to_string())
1732        );
1733    }
1734
1735    #[test]
1736    fn night_shift_can_increase_roll_by_paying_life() {
1737        let mut game = GameState::new(&["Alice", "Bob"], 20);
1738        let player = PlayerId(0);
1739        let mut card = Card::new(
1740            CardId(0),
1741            "Night Shift of the Living Dead".to_string(),
1742            player,
1743            CardTypeLine::parse("Creature Zombie Employee"),
1744            ManaCost::parse("2 B"),
1745            ColorSet::BLACK,
1746            Some(2),
1747            Some(3),
1748            vec!["After you roll a die, you may pay 1 life. If you do, increase or decrease the result by 1. Do this only once each turn.".to_string()],
1749            vec![],
1750        );
1751        card.set_s_var("RollModifyCost", "PayLife<1>");
1752        card.set_s_var("RollModificationsLimit", "1");
1753        card.set_s_var("ModsThisTurn", "Number$0");
1754        card.zone = forge_foundation::ZoneType::Battlefield;
1755        let card_id = game.create_card(card);
1756        game.add_card_to_zone(forge_foundation::ZoneType::Battlefield, player, card_id);
1757
1758        let mut agents: Vec<Box<dyn PlayerAgent>> =
1759            vec![Box::new(ModifyAgent), Box::new(ModifyAgent)];
1760        let mut trigger_handler = TriggerHandler::new();
1761        let mut mana_pools = vec![ManaPool::default(), ManaPool::default()];
1762        let mut rolls = vec![3];
1763
1764        let results = apply_simple_roll_modifiers(
1765            &mut game,
1766            &mut trigger_handler,
1767            &mut agents,
1768            &mut mana_pools,
1769            player,
1770            &mut rolls,
1771            None,
1772        );
1773
1774        assert_eq!(
1775            results
1776                .iter()
1777                .map(|roll| roll.modified_value)
1778                .collect::<Vec<_>>(),
1779            vec![4]
1780        );
1781        assert_eq!(game.player(player).life, 19);
1782        assert_eq!(
1783            game.card(card_id).svars.get("ModsThisTurn"),
1784            Some(&"Number$1".to_string())
1785        );
1786    }
1787
1788    #[test]
1789    fn monitor_monitor_can_pay_to_reroll() {
1790        let mut game = GameState::new(&["Alice", "Bob"], 20);
1791        let player = PlayerId(0);
1792
1793        let mut source = Card::new(
1794            CardId(0),
1795            "Dice Source".to_string(),
1796            player,
1797            CardTypeLine::parse("Artifact"),
1798            ManaCost::parse(""),
1799            ColorSet::COLORLESS,
1800            None,
1801            None,
1802            vec![],
1803            vec![],
1804        );
1805        source.zone = forge_foundation::ZoneType::Battlefield;
1806        let source_id = game.create_card(source);
1807        game.add_card_to_zone(forge_foundation::ZoneType::Battlefield, player, source_id);
1808
1809        let mut monitor = Card::new(
1810            CardId(0),
1811            "Monitor Monitor".to_string(),
1812            player,
1813            CardTypeLine::parse("Creature Human Employee"),
1814            ManaCost::parse("2 U U"),
1815            ColorSet::BLUE,
1816            Some(2),
1817            Some(5),
1818            vec![
1819                "Once each turn, you may pay {1} to reroll one or more dice you rolled."
1820                    .to_string(),
1821            ],
1822            vec![],
1823        );
1824        monitor.set_s_var("RollModificationsLimit", "1");
1825        monitor.set_s_var("ModsThisTurn", "Number$0");
1826        monitor.set_s_var("RollRerollCost", "1");
1827        monitor.zone = forge_foundation::ZoneType::Battlefield;
1828        let monitor_id = game.create_card(monitor);
1829        game.add_card_to_zone(forge_foundation::ZoneType::Battlefield, player, monitor_id);
1830
1831        let mut plains = Card::new(
1832            CardId(0),
1833            "Plains".to_string(),
1834            player,
1835            CardTypeLine::parse("Basic Land Plains"),
1836            ManaCost::parse(""),
1837            ColorSet::COLORLESS,
1838            None,
1839            None,
1840            vec![],
1841            vec![],
1842        );
1843        plains.zone = forge_foundation::ZoneType::Battlefield;
1844        let plains_id = game.create_card(plains);
1845        game.add_card_to_zone(forge_foundation::ZoneType::Battlefield, player, plains_id);
1846
1847        let sa = SpellAbility::new_empty(Some(source_id), player);
1848        let mut trigger_handler = TriggerHandler::new();
1849        let mut agents: Vec<Box<dyn PlayerAgent>> =
1850            vec![Box::new(RerollAgent), Box::new(RerollAgent)];
1851        let mut mana_pools = vec![ManaPool::default(), ManaPool::default()];
1852        let mut rng = FixedRng::new(&[0, 5]);
1853        let token_templates = HashMap::new();
1854        let templates_variants: HashMap<(String, String), usize> = HashMap::new();
1855        let token_fallback: HashMap<String, String> = HashMap::new();
1856        let edition_dates: HashMap<String, String> = HashMap::new();
1857        let mut ctx = EffectContext {
1858            game: &mut game,
1859            combat: None,
1860            agents: &mut agents,
1861            trigger_handler: &mut trigger_handler,
1862            token_templates: &token_templates,
1863            token_art_variants: &templates_variants,
1864            token_fallback: &token_fallback,
1865            edition_dates: &edition_dates,
1866            mana_pools: &mut mana_pools,
1867            parent_target_card: None,
1868            rng: &mut rng,
1869        };
1870
1871        let result = roll_for_player(&mut ctx, &sa, source_id, player, 6, 1);
1872
1873        assert_eq!(result, 6);
1874        assert_eq!(
1875            ctx.game.card(monitor_id).svars.get("ModsThisTurn"),
1876            Some(&"Number$1".to_string())
1877        );
1878        assert!(ctx.game.card(plains_id).tapped);
1879        assert_eq!(ctx.game.player(player).num_rolls_this_turn, 2);
1880    }
1881
1882    #[test]
1883    fn ignore_lower_preserves_natural_result_for_trigger() {
1884        let mut game = GameState::new(&["Alice", "Bob"], 20);
1885        let player = PlayerId(0);
1886        let mut source = Card::new(
1887            CardId(0),
1888            "Dice Source".to_string(),
1889            player,
1890            CardTypeLine::parse("Artifact"),
1891            ManaCost::parse(""),
1892            ColorSet::COLORLESS,
1893            None,
1894            None,
1895            vec![],
1896            vec![],
1897        );
1898        source.zone = forge_foundation::ZoneType::Battlefield;
1899        let source_id = game.create_card(source);
1900        game.add_card_to_zone(forge_foundation::ZoneType::Battlefield, player, source_id);
1901
1902        let sa = SpellAbility::new_simple(
1903            Some(source_id),
1904            player,
1905            "DB$ Internal | IgnoreLower$ 1 | Modifier$ 2",
1906        );
1907        let mut trigger_handler = TriggerHandler::new();
1908        let mut agents: Vec<Box<dyn PlayerAgent>> =
1909            vec![Box::new(ModifyAgent), Box::new(ModifyAgent)];
1910        let mut mana_pools = vec![ManaPool::default(), ManaPool::default()];
1911        let mut rng = FixedRng::new(&[0, 4]);
1912        let token_templates = HashMap::new();
1913        let templates_variants: HashMap<(String, String), usize> = HashMap::new();
1914        let token_fallback: HashMap<String, String> = HashMap::new();
1915        let edition_dates: HashMap<String, String> = HashMap::new();
1916        let mut ctx = EffectContext {
1917            game: &mut game,
1918            combat: None,
1919            agents: &mut agents,
1920            trigger_handler: &mut trigger_handler,
1921            token_templates: &token_templates,
1922            token_art_variants: &templates_variants,
1923            token_fallback: &token_fallback,
1924            edition_dates: &edition_dates,
1925            mana_pools: &mut mana_pools,
1926            parent_target_card: None,
1927            rng: &mut rng,
1928        };
1929
1930        let result = roll_for_player(&mut ctx, &sa, source_id, player, 6, 2);
1931
1932        assert_eq!(result, 7);
1933        assert_eq!(ctx.game.player(player).num_rolls_this_turn, 2);
1934    }
1935}
1936
1937pub fn visit_attractions(
1938    game: &mut GameState,
1939    trigger_handler: &mut TriggerHandler,
1940    player: PlayerId,
1941    light: i32,
1942) {
1943    if light <= 0 {
1944        return;
1945    }
1946    let attractions: Vec<_> = game
1947        .cards_in_zone(forge_foundation::ZoneType::Battlefield, player)
1948        .iter()
1949        .copied()
1950        .filter(|&card_id| {
1951            let card = game.card(card_id);
1952            card.type_line
1953                .subtypes
1954                .iter()
1955                .any(|s| s.eq_ignore_ascii_case("Attraction"))
1956                && card.has_attraction_light(light)
1957        })
1958        .collect();
1959
1960    for card_id in attractions {
1961        let first_visit = !game.card(card_id).was_visited_this_turn();
1962        game.card_mut(card_id).visit_attraction();
1963        if first_visit {
1964            game.player_record_attraction_visit(player, 1);
1965        }
1966        trigger_handler.run_trigger(
1967            TriggerType::VisitAttraction,
1968            RunParams {
1969                card: Some(card_id),
1970                player: Some(player),
1971                ..Default::default()
1972            },
1973            false,
1974        );
1975    }
1976}
1977
1978fn resolve_result_sub_ability(
1979    ctx: &mut EffectContext,
1980    _sa: &SpellAbility,
1981    source_id: crate::ids::CardId,
1982    player: PlayerId,
1983    result: i32,
1984    result_str: &str,
1985) {
1986    for entry in result_str.split(',') {
1987        let parts: Vec<&str> = entry.splitn(2, ':').collect();
1988        if parts.len() != 2 {
1989            continue;
1990        }
1991        let selector = parts[0].trim();
1992        let svar_name = parts[1].trim();
1993        let matches = if selector.eq_ignore_ascii_case("Else") {
1994            true
1995        } else if let Some((start, end)) = selector.split_once('-') {
1996            let low = start.trim().parse::<i32>().ok();
1997            let high = end.trim().parse::<i32>().ok();
1998            matches!(low.zip(high), Some((low, high)) if result >= low && result <= high)
1999        } else {
2000            selector
2001                .parse::<i32>()
2002                .ok()
2003                .map(|value| result == value)
2004                .unwrap_or(false)
2005        };
2006        if matches {
2007            if let Some(sub_text) = ctx
2008                .game
2009                .card(source_id)
2010                .get_s_var(svar_name)
2011                .map(str::to_string)
2012            {
2013                let mut sub_sa = build_spell_ability(ctx.game, source_id, &sub_text, player);
2014                sub_sa.activating_player = player;
2015                resolve_sub_chain(ctx, sub_sa);
2016            }
2017            break;
2018        }
2019    }
2020}
2021
2022fn resolve_sub_chain(ctx: &mut EffectContext, initial: SpellAbility) {
2023    let mut cur_opt: Option<SpellAbility> = Some(initial);
2024    while let Some(cur_sa) = cur_opt {
2025        super::resolve_effect(ctx, &cur_sa);
2026        cur_opt = cur_sa.sub_ability.map(|b| *b);
2027        if ctx.game.game_over {
2028            break;
2029        }
2030    }
2031}