|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import dataclasses |
| 4 | +from copy import deepcopy |
| 5 | +from typing import List, Callable, Any, Dict |
| 6 | + |
| 7 | +from adventofcode.util.exceptions import SolutionNotFoundException |
| 8 | +from adventofcode.util.helpers import solution_timer, memoize |
| 9 | +from adventofcode.util.input_helpers import get_input_for_day |
| 10 | + |
| 11 | +""" |
| 12 | +Magic Missile costs 53 mana. It instantly does 4 damage. |
| 13 | +Drain costs 73 mana. It instantly does 2 damage and heals you for 2 hit points. |
| 14 | +Shield costs 113 mana. It starts an effect that lasts for 6 turns. While it is active, your armor is increased by 7. |
| 15 | +Poison costs 173 mana. It starts an effect that lasts for 6 turns. At the start of each turn while it is active, it deals the boss 3 damage. |
| 16 | +Recharge costs 229 mana. It starts an effect that lasts for 5 turns. At the start of each turn while it is active, it gives you 101 new mana. |
| 17 | +""" |
| 18 | + |
| 19 | + |
| 20 | +@dataclasses.dataclass |
| 21 | +class Spell: |
| 22 | + name: str |
| 23 | + cost: int |
| 24 | + damage: int |
| 25 | + health: int |
| 26 | + shield: int |
| 27 | + mana: int |
| 28 | + duration: int |
| 29 | + |
| 30 | + def __key(self): |
| 31 | + return self.name, self.cost, self.damage, self.health, self.shield, self.mana, self.duration |
| 32 | + |
| 33 | + def __eq__(self, other): |
| 34 | + if not isinstance(other, Spell): |
| 35 | + raise NotImplementedError() |
| 36 | + |
| 37 | + return self.__key() == other.__key() |
| 38 | + |
| 39 | + def __hash__(self): |
| 40 | + return hash(self.__key()) |
| 41 | + |
| 42 | + |
| 43 | +missile = Spell('magic missile', 53, 4, 0, 0, 0, 0) |
| 44 | +drain = Spell('drain', 73, 2, 2, 0, 0, 0) |
| 45 | +shield = Spell('shield', 113, 0, 0, 7, 0, 6) |
| 46 | +poison = Spell('poison', 173, 3, 0, 0, 0, 6) |
| 47 | +recharge = Spell('recharge', 229, 0, 0, 0, 101, 5) |
| 48 | +spells = [missile, drain, shield, poison, recharge] |
| 49 | + |
| 50 | + |
| 51 | +def apply_status_effects(status_effects: tuple[Spell, ...], boss_health: int, player_health: int, player_armor: int, |
| 52 | + player_mana: int) -> tuple[tuple[Spell, ...], int, int, int, int]: |
| 53 | + next_effects: tuple[Spell, ...] = () |
| 54 | + for effect in status_effects: |
| 55 | + if effect.duration >= 0: |
| 56 | + boss_health -= effect.damage |
| 57 | + player_health += effect.health |
| 58 | + player_armor += effect.shield |
| 59 | + player_mana += effect.mana |
| 60 | + |
| 61 | + new_effect = Spell(effect.name, effect.cost, effect.damage, effect.health, effect.shield, effect.mana, |
| 62 | + effect.duration - 1) |
| 63 | + |
| 64 | + if new_effect.duration > 0: |
| 65 | + next_effects = next_effects + (new_effect,) |
| 66 | + |
| 67 | + return next_effects, boss_health, player_health, player_armor, player_mana |
| 68 | + |
| 69 | + |
| 70 | +def do_player_turn(next_effects: tuple[Spell, ...], boss_health: int, player_health: int, player_mana: int, mana_spent: int, |
| 71 | + simulation_func: Callable[[int, int, int, tuple[Spell, ...], bool, int], bool]): |
| 72 | + for spell in spells: |
| 73 | + if spell.name in [effect.name for effect in next_effects]: |
| 74 | + continue |
| 75 | + |
| 76 | + if spell.cost > player_mana: |
| 77 | + continue |
| 78 | + |
| 79 | + copied_effects = deepcopy(next_effects) |
| 80 | + copied_effects = copied_effects + (spell,) |
| 81 | + |
| 82 | + simulation_func(boss_health, player_health, player_mana - spell.cost, tuple(copied_effects), False, |
| 83 | + mana_spent + spell.cost) |
| 84 | + |
| 85 | + |
| 86 | +def fight(player_starting_health: int, player_starting_mana: int, boss_starting_health: int, boss_damage: int, |
| 87 | + hard_mode: bool = False) -> int: |
| 88 | + minimum_mana = int(1e10) |
| 89 | + |
| 90 | + @memoize |
| 91 | + def run_simulation(boss_health, player_health, player_mana, status_effects: tuple[Spell], is_player_turn, |
| 92 | + mana_spent): |
| 93 | + nonlocal boss_damage |
| 94 | + player_armor = 0 |
| 95 | + |
| 96 | + if hard_mode and is_player_turn: |
| 97 | + player_health -= 1 |
| 98 | + if player_health <= 0: |
| 99 | + return False |
| 100 | + |
| 101 | + status_result = apply_status_effects(status_effects, boss_health, player_health, player_armor, player_mana) |
| 102 | + next_effects, boss_health, player_health, player_armor, player_mana = status_result |
| 103 | + |
| 104 | + nonlocal minimum_mana |
| 105 | + if boss_health <= 0: |
| 106 | + minimum_mana = min(minimum_mana, mana_spent) |
| 107 | + return True |
| 108 | + |
| 109 | + if mana_spent >= minimum_mana: |
| 110 | + return False |
| 111 | + |
| 112 | + if is_player_turn: |
| 113 | + do_player_turn(next_effects, boss_health, player_health, player_mana, mana_spent, run_simulation) |
| 114 | + else: |
| 115 | + player_health += player_armor - boss_damage if player_armor - boss_damage < 0 else -1 |
| 116 | + if player_health > 0: |
| 117 | + run_simulation(boss_health, player_health, player_mana, tuple(next_effects), True, mana_spent) |
| 118 | + |
| 119 | + run_simulation(boss_starting_health, player_starting_health, player_starting_mana, (), True, 0) |
| 120 | + |
| 121 | + return minimum_mana |
| 122 | + |
| 123 | + |
| 124 | +@solution_timer(2015, 22, 1) |
| 125 | +def part_one(_: List[str]): |
| 126 | + answer = fight(50, 500, 58, 9, False) |
| 127 | + |
| 128 | + if not answer: |
| 129 | + raise SolutionNotFoundException(2015, 22, 1) |
| 130 | + |
| 131 | + return answer |
| 132 | + |
| 133 | + |
| 134 | +@solution_timer(2015, 22, 2) |
| 135 | +def part_two(_: List[str]): |
| 136 | + answer = fight(50, 500, 58, 9, True) |
| 137 | + |
| 138 | + if not answer: |
| 139 | + raise SolutionNotFoundException(2015, 22, 2) |
| 140 | + |
| 141 | + return answer |
| 142 | + |
| 143 | + |
| 144 | +if __name__ == '__main__': |
| 145 | + data = get_input_for_day(2015, 22) |
| 146 | + part_one(data) |
| 147 | + part_two(data) |
0 commit comments