aoc/year2015/
day22.rs

1//! # Wizard Simulator 20XX
2//!
3//! [A* algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) is ideal for solving this
4//! problem. A node in the graph is our current state and each edge is represented by casting a
5//! spell to get to a new state.
6//!
7//! The key to optimizing is to cache previously seen states. As we receive states in strictly
8//! increasing order of mana spent if we see a state again then it cannot possibly be optimal
9//! and we can discard it.  As an additional heuristic, for any given state, we know that we must
10//! spend a minimum mana on every turn, and that the game will last for at least as many
11//! turns as it requires for maximum damage to deplete the boss's hit points.  This heuristic
12//! does not take into account the fact that maximum damage is only possible while Poison is
13//! still active, where re-casting Poison costs more mana but can end the game faster; but
14//! that's okay: as long as the heuristic is consistent, underestimating is fine.
15use crate::util::hash::*;
16use crate::util::heap::*;
17use crate::util::iter::*;
18use crate::util::parse::*;
19use std::ops::ControlFlow;
20
21type Input = [i16; 2];
22
23#[derive(Clone, Copy, PartialEq, Eq, Hash)]
24struct State {
25    boss_hp: i16,
26    player_hp: i16,
27    player_mana: i16,
28    shield_effect: u8,
29    poison_effect: u8,
30    recharge_effect: u8,
31    spent: i16,
32}
33
34impl State {
35    /// Applies spell effects to state and returns true if the player has won.
36    #[inline]
37    fn apply_spell_effects(&mut self) -> bool {
38        if self.shield_effect > 0 {
39            self.shield_effect -= 1;
40        }
41        if self.poison_effect > 0 {
42            self.poison_effect -= 1;
43            self.boss_hp -= 3;
44        }
45        if self.recharge_effect > 0 {
46            self.recharge_effect -= 1;
47            self.player_mana += 101;
48        }
49
50        self.boss_hp <= 0
51    }
52
53    /// Applies boss attack and returns true if the wizard survives.
54    #[inline]
55    fn boss_turn(&mut self, mut attack: i16) -> bool {
56        if self.shield_effect > 0 {
57            attack = (attack - 7).max(1);
58        }
59
60        self.player_hp -= attack;
61        self.player_hp > 0 && self.player_mana >= 53
62    }
63}
64
65pub fn parse(input: &str) -> Input {
66    input.iter_signed().chunk::<2>().next().unwrap()
67}
68
69pub fn part1(input: &Input) -> i16 {
70    play(*input, false).break_value().unwrap()
71}
72
73pub fn part2(input: &Input) -> i16 {
74    play(*input, true).break_value().unwrap()
75}
76
77fn heuristic(spent: i16, boss_hp: i16) -> i16 {
78    // Assume that Poison is still active; this can deal the boss up to 6 damage prior to the next
79    // time we can cast.  Beyond that, we must spend mana every turn; the minimum is 53 for Magic
80    // Missile; and we need to survive at least as many turns as what the boss will survive even
81    // if we have maximum damage per turn (the most damage possible is 6 from Poison and 4 from
82    // Magic Missile from here on out).  Since this is a heuristic, it does not matter that it
83    // underestimates actual costs needed to keep Poison active, or that the boss will survive
84    // longer than the minimum number of turns for every time we cast a different spell.
85    let damage_per_turn = 4 + 6; // Poison still active and cast Magic Missile
86    let mana_per_turn = 53; // Magic Missile is cheapest to cast
87    spent + (boss_hp + (damage_per_turn - 1) - 6) / damage_per_turn * mana_per_turn
88}
89
90fn play(input: Input, hard_mode: bool) -> ControlFlow<i16> {
91    let [boss_hp, boss_damage] = input;
92    let start = State {
93        boss_hp,
94        player_hp: 50,
95        player_mana: 500,
96        shield_effect: 0,
97        poison_effect: 0,
98        recharge_effect: 0,
99        spent: 0,
100    };
101
102    let mut todo = MinHeap::new();
103    let mut cache = FastSet::with_capacity(5_000);
104
105    todo.push(heuristic(0, boss_hp), start);
106    cache.insert(start);
107
108    while let Some((_, mut state)) = todo.pop() {
109        let spent = state.spent;
110        // Check winning condition
111        if state.apply_spell_effects() {
112            return ControlFlow::Break(spent);
113        }
114
115        // Part two
116        if hard_mode {
117            if state.player_hp > 1 {
118                state.player_hp -= 1;
119            } else {
120                continue;
121            }
122        }
123
124        // Apply spell effects and boss turn, returning the winning mana spent if the boss dies.
125        let mut try_cast = |mut next: State| {
126            if next.apply_spell_effects() {
127                return ControlFlow::Break(next.spent);
128            }
129            if next.boss_turn(boss_damage) && cache.insert(next) {
130                todo.push(heuristic(next.spent, next.boss_hp), next);
131            }
132            ControlFlow::Continue(())
133        };
134
135        // Magic Missile
136        if state.player_mana >= 53 {
137            let next = State {
138                boss_hp: state.boss_hp - 4,
139                player_mana: state.player_mana - 53,
140                spent: spent + 53,
141                ..state
142            };
143            try_cast(next)?;
144        }
145
146        // Drain
147        if state.player_mana >= 73 {
148            let next = State {
149                boss_hp: state.boss_hp - 2,
150                player_hp: state.player_hp + 2,
151                player_mana: state.player_mana - 73,
152                spent: spent + 73,
153                ..state
154            };
155            try_cast(next)?;
156        }
157
158        // Shield
159        if state.player_mana >= 113 && state.shield_effect == 0 {
160            let next = State {
161                player_mana: state.player_mana - 113,
162                shield_effect: 6,
163                spent: spent + 113,
164                ..state
165            };
166            try_cast(next)?;
167        }
168
169        // Poison
170        if state.player_mana >= 173 && state.poison_effect == 0 {
171            let next = State {
172                player_mana: state.player_mana - 173,
173                poison_effect: 6,
174                spent: spent + 173,
175                ..state
176            };
177            try_cast(next)?;
178        }
179
180        // Recharge
181        if state.player_mana >= 229 && state.recharge_effect == 0 {
182            let next = State {
183                player_mana: state.player_mana - 229,
184                recharge_effect: 5,
185                spent: spent + 229,
186                ..state
187            };
188            try_cast(next)?;
189        }
190    }
191
192    unreachable!()
193}