diff --git a/24/a.py b/24/a.py new file mode 100755 index 0000000..6e0dde3 --- /dev/null +++ b/24/a.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 + + +""" +The immune system and the infection each have an army made up of several groups; +Each group consists of one or more identical units. +The armies repeatedly fight until only one army has units remaining. + +Units within a group all have the same hit points, attack damage, attack type, initiative, and sometimes weaknesses or immunities. + (higher initiative units attack first and win ties) + +Each group also has an effective power: the number of units in that group multiplied by their attack damage. + +Each fight consists of two phases: target selection and attacking. + +Target selection: + In decreasing order of effective power, + in a tie, the group with the higher initiative chooses first. + The attacking group chooses to target the group in the enemy army to which it would deal the most damage + (after accounting for weaknesses and immunities, + but not accounting for whether the defending group has enough units to actually receive all of that damage). + If an attacking group is considering two defending groups to which it would deal equal damage, + it chooses to target the defending group with the largest effective power; + if there is still a tie, it chooses the defending group with the highest initiative. + If it cannot deal any defending groups damage, it does not choose a target. + Defending groups can only be chosen as a target by one attacking group. + +Attacking phase: + each group deals damage to the target it selected, if any. Groups attack in decreasing order of initiative, + regardless of whether they are part of the infection or the immune system. + By default, an attacking group would deal damage equal to its effective power to the defending group. + Immune means no damage + Weak means 2x damage + The defending group only loses whole units from damage; + damage is always dealt in such a way that it kills the most units possible, + Any remaining damage to a unit that does not immediately kill it is ignored + + After the fight is over, if both armies still contain units, a new fight begins; + combat only ends once one army has lost all of its units. +""" + + +from pprint import pprint +import pdb +import re + + +RE_ARMY = re.compile(r'([0-9]+) units each with ([0-9]+) hit points (\(([^\)]+)\) )?with an attack that does ([0-9]+) ([a-z]+) damage at initiative ([0-9]+)') +RE_MODIFIER = re.compile(r'(weak|immune) to ([a-z, ]+)') + + +class Army(object): + def __init__(self, tag, team, units, hp, weaknesses, immunes, damage, damage_type, initiative): + # Group number for debugging purposes + self.tag = tag + # Team string name + self.team = team + # Number of units + self.units = units + # Health per unit + self.hp = hp + # set() of weaknesses (string names) + self.weak = weaknesses + # set() of immunities (string names) + self.immune = immunes + # attack power + self.damage = damage + # attack type (string name) + self.damtype = damage_type + # initiative level + self.initiative = initiative + + @property + def effpower(self): + return self.units * self.damage + + def __repr__(self): + mods = [] + modstr = "" + if self.immune: + mods.append("immunte:{}".format('|'.join(self.immune))) + if self.weak: + mods.append("weak:{}".format('|'.join(self.weak))) + if mods: + modstr = " {}".format(' '.join(mods)) + return "" \ + .format(self.tag, self.team, self.effpower, self.units, self.hp, self.damage, self.damtype, self.initiative, modstr) + + def attack(self, target, simulate=False): + """ + Perform a battle + :param target: army that self will attack + :param simulate: if true, don't modify the target army. + :return: maximum damage dealt if simulating + if not simulating, True if the target army was wiped out. False otherwise + """ + + """ + Attacking phase: + each group deals damage to the target it selected, if any. Groups attack in decreasing order of initiative, + regardless of whether they are part of the infection or the immune system. + By default, an attacking group would deal damage equal to its effective power to the defending group. + Immune means no damage + Weak means 2x damage + The defending group only loses whole units from damage; + damage is always dealt in such a way that it kills the most units possible, + Any remaining damage to a unit that does not immediately kill it is ignored + + After the fight is over, if both armies still contain units, a new fight begins; + combat only ends once one army has lost all of its units. + """ + + # Determine weakness/immunity damage multiplier + if self.damtype in target.immune: + multiplier = 0 + elif self.damtype in target.weak: + multiplier = 2 + else: + multiplier = 1 + + damage = multiplier * self.effpower + + # if multiplier == 0: + # return False, 0, 0 + + dead = damage // target.hp + killed = min(target.units, dead) + + if simulate: + return damage + + target.units -= killed + + return target.units == 0, damage, killed + + +def parsearmies(fname): + armies = [] + with open(fname) as f: + teamname = None + groupnum = 1 + for line in f.readlines(): + line = line.strip() + if not line: + continue + if ":" in line: # Found a new army like "Infection:" + teamname = line[0:-1] + groupnum = 1 + else: # an army line to parse + units, hp, _, modifiers, atkpwr, atktype, initia = RE_ARMY.search(line).groups() + mods = {"weak": set(), + "immune": set()} + if modifiers: + for prop in RE_MODIFIER.findall(modifiers): + mods[prop[0]].update(prop[1].split(', ')) + armies.append(Army(groupnum, teamname, int(units), int(hp), mods["weak"], mods["immune"], + int(atkpwr), atktype, int(initia))) + groupnum +=1 + return armies + + +def assignbattles(armies): + """ + Target selection: + In decreasing order of effective power, + in a tie, the group with the higher initiative chooses first. + The attacking group chooses to target the group in the enemy army to which it would deal the most damage + (after accounting for weaknesses and immunities, + but not accounting for whether the defending group has enough units to actually receive all of that damage). + If an attacking group is considering two defending groups to which it would deal equal damage, + it chooses to target the defending group with the largest effective power; + if there is still a tie, it chooses the defending group with the highest initiative. + If it cannot deal any defending groups damage, it does not choose a target. + Defending groups can only be chosen as a target by one attacking group. + """ + + battles = [] # list of tuples (attacker, defender) + # armies in this list need to be assigned a target + assign = sorted(armies, + key=lambda a: a.effpower * 100000000 + a.initiative, # strongest armies pick a target first + reverse=True) + + # print("Assign order") + # for ass in assign: + # print("- {} group {} (eff {})".format(ass.team, ass.tag, ass.effpower)) + # print() + + targets = set(assign) # these armies can still be attacked + while assign: + army = assign.pop(0) # we're finding a target for this army + best = None # best target + best_dmg = -1 # damage inflicted to best target + best_eff = -1 + best_init = -1 + considered = False + for target in targets: + if army.team == target.team or army == target: # don't attack same team + continue + considered = True + dmg = army.attack(target, simulate=True) + # print("{} {} -> {} {} damage: {} (target eff={}, init={})".format(army.team, army.tag, target.team, target.tag, dmg, target.effpower, target.initiative)) + if (dmg > best_dmg) or \ + (dmg == best_dmg and target.effpower > best_eff) or \ + (dmg == best_dmg and target.effpower == best_eff and target.initiative > best_init): + best = target + best_dmg = dmg + best_init = target.initiative + best_eff = target.effpower + + if not considered: # no targets available + continue + + if best_dmg == 0: + continue + + targets.remove(best) + battles.append((army, best)) + # print("\nBest target for ({} group {} (effpwr {}) :: dmg={})\n\t{} is \n\t{}\n\n".format(army.team, army.tag, army.effpower, best_dmg, army, best)) + + return sorted(battles, key=lambda b: b[0].initiative, reverse=True) + + +def main(): + armies = parsearmies("input.txt") + + while True: # each loop is one round of fighting + # print("\n========== Round ==========") + battles = assignbattles(armies) + + if not battles: # one side has been wiped out + break + + # print() + + for attacker, defender in battles: + if attacker.units > 0: + wiped, dmg, killed = attacker.attack(defender) + # print("{} group {} (i{}) attacks {} group {}, dealing {}, killing {}".format(attacker.team, attacker.tag, attacker.initiative, defender.team, defender.tag, dmg, killed)) + if wiped: + # print("{} group {} is wiped out! ({})".format(defender.team, defender.tag, defender.units)) + armies.remove(defender) + + if len(battles) == 1: + break + + # pdb.set_trace() + # input() + + # print() + # print() + # pprint(armies) + print(sum([i.units for i in armies])) + + +if __name__ == '__main__': + main() diff --git a/24/b.py b/24/b.py new file mode 100755 index 0000000..a404880 --- /dev/null +++ b/24/b.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + + +from sys import exit +import re + + +RE_ARMY = re.compile(r'([0-9]+) units each with ([0-9]+) hit points (\(([^\)]+)\) )?with an attack that does ([0-9]+) ([a-z]+) damage at initiative ([0-9]+)') +RE_MODIFIER = re.compile(r'(weak|immune) to ([a-z, ]+)') + + +class Army(object): + def __init__(self, tag, team, units, hp, weaknesses, immunes, damage, damage_type, initiative): + # Group number for debugging purposes + self.tag = tag + # Team string name + self.team = team + # Number of units + self.units = units + # Health per unit + self.hp = hp + # set() of weaknesses (string names) + self.weak = weaknesses + # set() of immunities (string names) + self.immune = immunes + # attack power + self.damage = damage + # attack type (string name) + self.damtype = damage_type + # initiative level + self.initiative = initiative + + @property + def effpower(self): + return self.units * self.damage + + def __repr__(self): + mods = [] + modstr = "" + if self.immune: + mods.append("immunte:{}".format('|'.join(self.immune))) + if self.weak: + mods.append("weak:{}".format('|'.join(self.weak))) + if mods: + modstr = " {}".format(' '.join(mods)) + return "" \ + .format(self.tag, self.team, self.effpower, self.units, self.hp, self.damage, self.damtype, self.initiative, modstr) + + def attack(self, target, simulate=False): + """ + Perform a battle + :param target: army that self will attack + :param simulate: if true, don't modify the target army. + :return: maximum damage dealt if simulating + if not simulating, True if the target army was wiped out. False otherwise + """ + if self.damtype in target.immune: + multiplier = 0 + elif self.damtype in target.weak: + multiplier = 2 + else: + multiplier = 1 + + damage = multiplier * self.effpower + + dead = damage // target.hp + killed = min(target.units, dead) + + if simulate: + return damage + + target.units -= killed + + return target.units == 0, damage, killed + + +def parsearmies(fname): + armies = [] + with open(fname) as f: + teamname = None + groupnum = 1 + for line in f.readlines(): + line = line.strip() + if not line: + continue + if ":" in line: # Found a new army like "Infection:" + teamname = line[0:-1] + groupnum = 1 + else: # an army line to parse + units, hp, _, modifiers, atkpwr, atktype, initia = RE_ARMY.search(line).groups() + mods = {"weak": set(), + "immune": set()} + if modifiers: + for prop in RE_MODIFIER.findall(modifiers): + mods[prop[0]].update(prop[1].split(', ')) + armies.append(Army(groupnum, teamname, int(units), int(hp), mods["weak"], mods["immune"], + int(atkpwr), atktype, int(initia))) + groupnum +=1 + return armies + + +def assignbattles(armies): + battles = [] # list of tuples (attacker, defender) + # armies in this list need to be assigned a target + assign = sorted(armies, + key=lambda a: a.effpower * 100000000 + a.initiative, # strongest armies pick a target first + reverse=True) + + targets = set(assign) # these armies can still be attacked + while assign: + army = assign.pop(0) # we're finding a target for this army + best = None # best target + best_dmg = -1 # damage inflicted to best target + best_eff = -1 + best_init = -1 + considered = False + for target in targets: + if army.team == target.team or army == target: # don't attack same team + continue + considered = True + dmg = army.attack(target, simulate=True) + if (dmg > best_dmg) or \ + (dmg == best_dmg and target.effpower > best_eff) or \ + (dmg == best_dmg and target.effpower == best_eff and target.initiative > best_init): + best = target + best_dmg = dmg + best_init = target.initiative + best_eff = target.effpower + + if not considered: # no targets available + continue + + if best_dmg == 0: + continue + + targets.remove(best) + battles.append((army, best)) + + return sorted(battles, key=lambda b: b[0].initiative, reverse=True) + + +def runsim(armies): + while True: # each loop is one round of fighting + battles = assignbattles(armies) + + if not battles: # one side has been wiped out + break + + had_casualties = False + for attacker, defender in battles: + if attacker.units > 0: + wiped, dmg, killed = attacker.attack(defender) + had_casualties = had_casualties or killed > 0 + if wiped: + armies.remove(defender) + + if len(battles) == 1: + break + + if not had_casualties: + return + + if armies[0].team == "Immune System": + print(sum([i.units for i in armies])) + exit(0) + + +def main(): + boost = 80 # semi-arbitrary starting point to save time. Tweak if it doesn't fit your input + while True: + armies = parsearmies("input.txt") + for unit in armies: + if unit.team == "Immune System": + unit.damage += boost + print(boost) + runsim(armies) + boost += 1 + + + +if __name__ == '__main__': + main() diff --git a/24/input.txt b/24/input.txt new file mode 100644 index 0000000..9e2d14a --- /dev/null +++ b/24/input.txt @@ -0,0 +1,23 @@ +Immune System: +1514 units each with 8968 hit points (weak to cold) with an attack that does 57 bludgeoning damage at initiative 9 +2721 units each with 6691 hit points (weak to cold) with an attack that does 22 slashing damage at initiative 15 +1214 units each with 10379 hit points (immune to bludgeoning) with an attack that does 69 fire damage at initiative 16 +2870 units each with 4212 hit points with an attack that does 11 radiation damage at initiative 12 +1239 units each with 5405 hit points (weak to cold) with an attack that does 37 cold damage at initiative 18 +4509 units each with 4004 hit points (weak to cold; immune to radiation) with an attack that does 8 slashing damage at initiative 20 +3369 units each with 10672 hit points (weak to slashing) with an attack that does 29 cold damage at initiative 11 +2890 units each with 11418 hit points (weak to fire; immune to bludgeoning) with an attack that does 30 cold damage at initiative 8 +149 units each with 7022 hit points (weak to slashing) with an attack that does 393 radiation damage at initiative 13 +2080 units each with 5786 hit points (weak to fire; immune to slashing, bludgeoning) with an attack that does 20 fire damage at initiative 7 + +Infection: +817 units each with 47082 hit points (immune to slashing, radiation, bludgeoning) with an attack that does 115 cold damage at initiative 3 +4183 units each with 35892 hit points with an attack that does 16 bludgeoning damage at initiative 1 +7006 units each with 11084 hit points with an attack that does 2 fire damage at initiative 2 +4804 units each with 25411 hit points with an attack that does 10 cold damage at initiative 14 +6262 units each with 28952 hit points (weak to fire) with an attack that does 7 slashing damage at initiative 10 +628 units each with 32906 hit points (weak to slashing) with an attack that does 99 radiation damage at initiative 4 +5239 units each with 46047 hit points (immune to fire) with an attack that does 14 bludgeoning damage at initiative 6 +1173 units each with 32300 hit points (weak to cold, slashing) with an attack that does 53 bludgeoning damage at initiative 19 +3712 units each with 12148 hit points (immune to cold; weak to slashing) with an attack that does 5 slashing damage at initiative 17 +334 units each with 43582 hit points (weak to cold, fire) with an attack that does 260 cold damage at initiative 5