2018-12-24 - Immune System Simulator 20XX
(original .ipynb)
puzzle_input_lines = open("puzzle_input/day24.txt").readlines()
test_input_lines = """Immune System:
17 units each with 5390 hit points (weak to radiation, bludgeoning) with an attack that does 4507 fire damage at initiative 2
989 units each with 1274 hit points (immune to fire; weak to bludgeoning, slashing) with an attack that does 25 slashing damage at initiative 3
Infection:
801 units each with 4706 hit points (weak to radiation) with an attack that does 116 bludgeoning damage at initiative 1
4485 units each with 2961 hit points (immune to radiation; weak to fire, cold) with an attack that does 12 slashing damage at initiative 4""".split("\n")
from collections import defaultdict
import re
# I don't like using classes to solve these problems, but this was the simplest way
class UnitGroup:
def __init__(self, identifier, army, unit_group, boost=0):
units, hit_points, strengths, weaknesses, damage, damage_type, initiative = unit_group
self.identifier = f"{army}.{identifier}"
self.army = army
self.units = units
self.hit_points = hit_points
self.strengths = strengths
self.weaknesses = weaknesses
self.damage = damage + boost
self.damage_type = damage_type
self.initiative = initiative
def effective_power(self):
return self.units * self.damage
def select_target(self, unit_groups, targets):
highest_damage = 0
selected_target = None
for other in sorted(unit_groups, key=effective_power_and_initiative, reverse=True):
if self.army == other.army or other.identifier in targets.values() or other.units < 1:
continue
damage = self.calculate_damage(other)
if damage > highest_damage:
selected_target = other.identifier
highest_damage = damage
return selected_target
def calculate_damage(self, enemy):
total_damage = self.units * self.damage
if enemy.strengths and self.damage_type in enemy.strengths:
total_damage *= 0
if enemy.weaknesses and self.damage_type in enemy.weaknesses:
total_damage *= 2
return total_damage
def attack(self, enemy):
if self.units < 1:
return
total_damage = self.calculate_damage(enemy)
killed_units = min(enemy.units, total_damage // enemy.hit_points)
enemy.units -= killed_units
return killed_units
def parse_strengths_weaknesses(weakness_str):
strengths, weaknesses = None, None
if weakness_str is not None:
weakness_str = weakness_str.lstrip("(")
weakness_str = weakness_str.rstrip(")")
parts = weakness_str.split("; ")
weak_str = "weak to"
immune_str = "immune to"
for part in parts:
if part.startswith(weak_str):
weaknesses = part.lstrip(weak_str).split(", ")
elif part.startswith(immune_str):
strengths = part.lstrip(immune_str).split(", ")
return strengths, weaknesses
def parse_input_line(input_line):
if army_match := re.match("^(.+)\:$", input_line):
return army_match.group(1), None
elif unit_group_match := re.match("^(\d+) units each with (\d+) hit points (\(.+\))?\s?with an attack that does (\d+) (\w+) damage at initiative (\d+)$", input_line):
units = int(unit_group_match.group(1))
hit_points = int(unit_group_match.group(2))
strengths, weaknesses = parse_strengths_weaknesses(unit_group_match.group(3))
damage = int(unit_group_match.group(4))
damage_type = unit_group_match.group(5)
initiative = int(unit_group_match.group(6))
return None, (units, hit_points, strengths, weaknesses, damage, damage_type, initiative)
else:
return None, None
def parse_input(input_lines, boost=0):
current_army = None
unit_groups = []
for i, line in enumerate(input_lines):
army, unit_group = parse_input_line(line)
if army:
current_army = army
elif unit_group:
if current_army == "Immune System":
unit_groups.append(UnitGroup(i, current_army, unit_group, boost))
else:
unit_groups.append(UnitGroup(i, current_army, unit_group))
else:
pass
return unit_groups
def effective_power(unit_group):
return unit_group.effective_power()
def effective_power_and_initiative(unit_group):
return (unit_group.effective_power(), unit_group.initiative)
def initiative(unit_group):
return unit_group.initiative
def get_unit_group(unit_groups, unit_group_id):
for unit_group in unit_groups:
if unit_group.identifier == unit_group_id:
return unit_group
return None
def armies_remaining(unit_groups):
armies = set()
for unit_group in unit_groups:
if unit_group.units > 0:
armies.add(unit_group.army)
return len(armies)
def fight(unit_groups):
killed_units = 1
while killed_units > 0:
killed_units = 0
# target selection
targets = {}
for unit_group in sorted(unit_groups, key=effective_power_and_initiative, reverse=True):
target = unit_group.select_target(unit_groups, targets)
if target is not None:
targets[unit_group.identifier] = target
# attacking
for unit_group in sorted(unit_groups, key=initiative, reverse=True):
if unit_group.units < 1 or unit_group.identifier not in targets:
continue
other = get_unit_group(unit_groups, targets[unit_group.identifier])
killed_units += unit_group.attack(other)
def part_one(input_lines):
unit_groups = parse_input(input_lines)
fight(unit_groups)
return sum(ug.units for ug in unit_groups)
assert 5216 == part_one(test_input_lines)
print(part_one(puzzle_input_lines))
14854
def remaining_units(unit_groups, army):
total = 0
for unit_group in unit_groups:
if unit_group.army == army:
total += unit_group.units
return total
def part_two(input_lines):
unit_groups = parse_input(input_lines)
boost = 1
fight(unit_groups)
immune_units = remaining_units(unit_groups, "Immune System")
infection_units = remaining_units(unit_groups, "Infection")
while immune_units == 0 or infection_units > 0:
# I had a much-faster binary search, but this is cleaner to read :)
boost += 1
unit_groups = parse_input(input_lines, boost)
fight(unit_groups)
immune_units = remaining_units(unit_groups, "Immune System")
infection_units = remaining_units(unit_groups, "Infection")
return sum(ug.units for ug in unit_groups)
assert 51 == part_two(test_input_lines)
print(part_two(puzzle_input_lines))
3467