Skip to main content

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