Skip to content

Commit b47723c

Browse files
solution: add 2015 day 21 and 22
1 parent 24581ca commit b47723c

File tree

2 files changed

+299
-0
lines changed

2 files changed

+299
-0
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import dataclasses
2+
import itertools
3+
from typing import List
4+
5+
from adventofcode.util.exceptions import SolutionNotFoundException
6+
from adventofcode.util.helpers import solution_timer
7+
from adventofcode.util.input_helpers import get_input_for_day
8+
9+
10+
@dataclasses.dataclass
11+
class Item:
12+
name: str
13+
cost: int
14+
damage: int
15+
armor: int
16+
17+
18+
class Weapon(Item):
19+
pass
20+
21+
22+
class Armor(Item):
23+
pass
24+
25+
26+
class Ring(Item):
27+
pass
28+
29+
30+
@dataclasses.dataclass
31+
class Character:
32+
health: int
33+
damage: int
34+
armor: int
35+
36+
37+
def get_shop_inventory():
38+
dagger = Weapon('Dagger', 8, 4, 0)
39+
shortsword = Weapon('Shortsword', 10, 5, 0)
40+
warhammer = Weapon('Warhammer', 25, 6, 0)
41+
longsword = Weapon('Longsword', 40, 7, 0)
42+
greataxe = Weapon('Greataxe', 74, 8, 0)
43+
empty_hand = Weapon('Empty', 0, 0, 0)
44+
weapons = [dagger, shortsword, warhammer, longsword, greataxe, empty_hand]
45+
46+
leather = Armor('Leather', 13, 0, 1)
47+
chainmail = Armor('Chainmail', 31, 0, 2)
48+
splintmail = Armor('Splintmail', 53, 0, 3)
49+
bandedmail = Armor('Bandedmail', 75, 0, 4)
50+
platemail = Armor('Platemail', 102, 0, 5)
51+
naked = Armor('Naked', 0, 0, 0)
52+
armor = [leather, chainmail, splintmail, bandedmail, platemail, naked]
53+
54+
damage_1 = Ring('Damage +1', 25, 1, 0)
55+
damage_2 = Ring('Damage +2', 50, 2, 0)
56+
damage_3 = Ring('Damage +3', 100, 3, 0)
57+
defense_1 = Ring('Defense +1', 20, 0, 1)
58+
defense_2 = Ring('Defense +2', 40, 0, 2)
59+
defense_3 = Ring('Defense +3', 80, 0, 3)
60+
no_jewelry = Ring('No jewelry', 0, 0, 0)
61+
rings = [damage_1, damage_2, damage_3, defense_1, defense_2, defense_3, no_jewelry]
62+
63+
return weapons, armor, rings
64+
65+
66+
def calculate_cost(boss: Character):
67+
weapons, armor, rings = get_shop_inventory()
68+
69+
wins: List[int] = []
70+
losses: List[int] = []
71+
72+
for weapon in weapons:
73+
# only one weapon
74+
for armor_item in armor:
75+
# only one armor
76+
for ring_one, ring_two in itertools.combinations(rings, 2):
77+
# two rings
78+
total_cost = weapon.cost + armor_item.cost + ring_one.cost + ring_two.cost
79+
total_damage = weapon.damage + armor_item.damage + ring_one.damage + ring_two.damage
80+
total_armor = weapon.armor + armor_item.armor + ring_one.armor + ring_two.armor
81+
82+
player = Character(health=100, damage=total_damage, armor=total_armor)
83+
84+
if fight(player, boss):
85+
wins.append(total_cost)
86+
else:
87+
losses.append(total_cost)
88+
89+
return min(wins), max(losses)
90+
91+
92+
def get_damage(attacker: Character, defender: Character) -> int:
93+
hit_points = attacker.damage - defender.armor
94+
95+
if hit_points <= 0:
96+
return 1
97+
98+
return hit_points
99+
100+
101+
def get_boss(input_data: List[str]) -> Character:
102+
health = int(input_data[0].split(': ')[1])
103+
damage = int(input_data[1].split(': ')[1])
104+
armor = int(input_data[2].split(': ')[1])
105+
return Character(health, damage, armor)
106+
107+
108+
def fight(player: Character, boss: Character) -> bool:
109+
player_health = player.health
110+
boss_health = player.health
111+
112+
while True:
113+
# Player goes first
114+
boss_health -= get_damage(player, boss)
115+
116+
if boss_health <= 0:
117+
return True
118+
119+
# Boss is next
120+
player_health -= get_damage(boss, player)
121+
122+
if player_health <= 0:
123+
return False
124+
125+
126+
@solution_timer(2015, 21, 1)
127+
def part_one(input_data: List[str]):
128+
boss = get_boss(input_data)
129+
answer, _ = calculate_cost(boss)
130+
131+
if not answer:
132+
raise SolutionNotFoundException(2015, 21, 1)
133+
134+
return answer
135+
136+
137+
# Solution isn't correct for part 2
138+
# @solution_timer(2015, 21, 2)
139+
# def part_two(input_data: List[str]):
140+
# boss = get_boss(input_data)
141+
# _, answer = calculate_cost(boss)
142+
#
143+
# if not answer:
144+
# raise SolutionNotFoundException(2015, 21, 2)
145+
#
146+
# return answer
147+
148+
149+
if __name__ == '__main__':
150+
data = get_input_for_day(2015, 21)
151+
part_one(data)
152+
# part_two(data)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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

Comments
 (0)