Sean McLemon | Advent of Code

Home | Czech | Blog | GitHub | Advent Of Code | Notes


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