Skip to Content

Advent of Code 2018 - Day 24, in Kotlin

Kotlin solutions to parts 1 and 2 of Advent of Code 2018, Day 24: 'Immune System Simulator 20XX'

Posted on

On the penultimate date of Advent of Code 2018, we get to do more fighting simulation! And just like Day 15, order of operations is really important. As a consequence, we’ll get to know all about sorting collections!

If you’d rather just view code, the GitHub Repository is here.

Problem Input

I decided to make life easier and break my input into two parts: immune system and infection. So in my repo, it will be two files that get loaded instead of one. This just made the parsing easier. We will parse these individual files below.

Day 24, Part 1

After a weird buzzing noise, you appear back at the man’s cottage. He seems relieved to see his friend, but quickly notices that the little reindeer caught some kind of cold while out exploring.

The portly man explains that this reindeer’s immune system isn’t similar to regular reindeer immune systems:

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 (amount of damage a unit can take before it is destroyed), attack damage (the amount of damage each unit deals), an attack type, an initiative (higher initiative units attack first and win ties), and sometimes weaknesses or immunities. Here is an example group:

18 units each with 729 hit points (weak to fire; immune to cold, slashing) with an attack that does 8 radiation damage at initiative 10 Each group also has an effective power: the number of units in that group multiplied by their attack damage. The above group has an effective power of 18 * 8 = 144. Groups never have zero or negative units; instead, the group is removed from combat.

Each fight consists of two phases: target selection and attacking.

During the target selection phase, each group attempts to choose one target. In decreasing order of effective power, groups choose their targets; 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.

At the end of the target selection phase, each group has selected zero or one groups to attack, and each group is being attacked by zero or one groups.

During the 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. (If a group contains no units, it cannot attack.)

The damage an attacking group deals to a defending group depends on the attacking group’s attack type and the defending group’s immunities and weaknesses. By default, an attacking group would deal damage equal to its effective power to the defending group. However, if the defending group is immune to the attacking group’s attack type, the defending group instead takes no damage; if the defending group is weak to the attacking group’s attack type, the defending group instead takes double 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, and any remaining damage to a unit that does not immediately kill it is ignored. For example, if a defending group contains 10 units with 10 hit points each and receives 75 damage, it loses exactly 7 units and is left with 3 units at full health.

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.

For example, consider the following armies:

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

If these armies were to enter combat, the following fights, including details during the target selection and attacking phases, would take place:

Immune System:
Group 1 contains 17 units
Group 2 contains 989 units
Infection:
Group 1 contains 801 units
Group 2 contains 4485 units

Infection group 1 would deal defending group 1 185832 damage
Infection group 1 would deal defending group 2 185832 damage
Infection group 2 would deal defending group 2 107640 damage
Immune System group 1 would deal defending group 1 76619 damage
Immune System group 1 would deal defending group 2 153238 damage
Immune System group 2 would deal defending group 1 24725 damage

Infection group 2 attacks defending group 2, killing 84 units
Immune System group 2 attacks defending group 1, killing 4 units
Immune System group 1 attacks defending group 2, killing 51 units
Infection group 1 attacks defending group 1, killing 17 units
Immune System:
Group 2 contains 905 units
Infection:
Group 1 contains 797 units
Group 2 contains 4434 units

Infection group 1 would deal defending group 2 184904 damage
Immune System group 2 would deal defending group 1 22625 damage
Immune System group 2 would deal defending group 2 22625 damage

Immune System group 2 attacks defending group 1, killing 4 units
Infection group 1 attacks defending group 2, killing 144 units
Immune System:
Group 2 contains 761 units
Infection:
Group 1 contains 793 units
Group 2 contains 4434 units

Infection group 1 would deal defending group 2 183976 damage
Immune System group 2 would deal defending group 1 19025 damage
Immune System group 2 would deal defending group 2 19025 damage

Immune System group 2 attacks defending group 1, killing 4 units
Infection group 1 attacks defending group 2, killing 143 units
Immune System:
Group 2 contains 618 units
Infection:
Group 1 contains 789 units
Group 2 contains 4434 units

Infection group 1 would deal defending group 2 183048 damage
Immune System group 2 would deal defending group 1 15450 damage
Immune System group 2 would deal defending group 2 15450 damage

Immune System group 2 attacks defending group 1, killing 3 units
Infection group 1 attacks defending group 2, killing 143 units
Immune System:
Group 2 contains 475 units
Infection:
Group 1 contains 786 units
Group 2 contains 4434 units

Infection group 1 would deal defending group 2 182352 damage
Immune System group 2 would deal defending group 1 11875 damage
Immune System group 2 would deal defending group 2 11875 damage

Immune System group 2 attacks defending group 1, killing 2 units
Infection group 1 attacks defending group 2, killing 142 units
Immune System:
Group 2 contains 333 units
Infection:
Group 1 contains 784 units
Group 2 contains 4434 units

Infection group 1 would deal defending group 2 181888 damage
Immune System group 2 would deal defending group 1 8325 damage
Immune System group 2 would deal defending group 2 8325 damage

Immune System group 2 attacks defending group 1, killing 1 unit
Infection group 1 attacks defending group 2, killing 142 units
Immune System:
Group 2 contains 191 units
Infection:
Group 1 contains 783 units
Group 2 contains 4434 units

Infection group 1 would deal defending group 2 181656 damage
Immune System group 2 would deal defending group 1 4775 damage
Immune System group 2 would deal defending group 2 4775 damage

Immune System group 2 attacks defending group 1, killing 1 unit
Infection group 1 attacks defending group 2, killing 142 units
Immune System:
Group 2 contains 49 units
Infection:
Group 1 contains 782 units
Group 2 contains 4434 units

Infection group 1 would deal defending group 2 181424 damage
Immune System group 2 would deal defending group 1 1225 damage
Immune System group 2 would deal defending group 2 1225 damage

Immune System group 2 attacks defending group 1, killing 0 units
Infection group 1 attacks defending group 2, killing 49 units
Immune System:
No groups remain.
Infection:
Group 1 contains 782 units
Group 2 contains 4434 units

In the example above, the winning army ends up with 782 + 4434 = 5216 units.

You scan the reindeer’s condition (your puzzle input); the white-bearded man looks nervous. As it stands now, how many units would the winning army have?

Let’s start by writing a small enum to identify which team the groups are on…

// In Day24

private enum class Team {
    ImmuneSystem,
    Infection
}

And from there we can start writing our Group data class, which we will extend later.

// In Day24

private data class Group(
    val team: Team,
    var units: Int,
    val unitHp: Int,
    val unitDamage: Int,
    val attackType: String,
    val initiative: Int,
    val weakTo: Set<String>,
    val immuneTo: Set<String>,
    var alive: Boolean = true
) {
    fun effectivePower(): Int =
        units * unitDamage
}

As you can see, we’ve made units and alive vars rather than vals. This is because during the fighting these can change and I don’t want to add the complexity to handle that with immutable Group classes. I’d rather have these be mutable and used simply and under controlled conditions rather then have the be immutable just for the sake of it. I usually try to go for immutability when I can, but this is one of those times when it’s not worth it to me.

We also calculate the effectivePower here, which can change over time (because the number of units can change) so it is a function and not a property. We will store its attack type, weaknesses, and immunities as simple Strings because we don’t really care what they are just that they match (or don’t). In theory we could just make team a String as well, but I feel the code looks nicer when doing comparisons against specific teams later.

Parsing

As I mentioned above, I’ve altered the input given to us just a little bit. Rather than one file that describes both groups in different sections that have headers, I’ve just broken it up into two files. I really only did it because it simplifies things while parsing, which is already pretty involved.

We’ll begin parsing the same way we have for most of the other days - with a companion function called of on our target class (in this casse Group).

// In Group, In Day24

companion object {
    private val pattern = """^(\d+) units each with (\d+) hit points (\([,; a-z]+\) )?with an attack that does (\d+) (\w+) damage at initiative (\d+)$""".toRegex()

    fun of(team: Team, input: String): Group {
        pattern.find(input)?.let {
            val (units, unitHp, strongWeak, unitDamage, attackType, initiative) = it.destructured
            return Group(
                team = team,
                units = units.toInt(),
                unitHp = unitHp.toInt(),
                unitDamage = unitDamage.toInt(),
                attackType = attackType,
                initiative = initiative.toInt(),
                weakTo = parseStrongWeak("weak", strongWeak),
                immuneTo = parseStrongWeak("immune", strongWeak)
            )
        }
    }

    private fun parseStrongWeak(kind: String, input: String): Set<String> =
        if (input.isBlank()) emptySet()
        else {
            val found = input.drop(1).dropLast(2).split("; ").filter { it.startsWith(kind) }
            if (found.isEmpty()) {
                emptySet()
            } else {
                found.first().split("to ")[1].split(",").map { it.trim() }.toSet()
            }
        }
}

Parsing out the digits should look familiar because they are in fixed locations. The tricky part is figuring out which weaknesses and immunities each group has. For that, we are matching against the possibility that there is a parenthetical group there. Even if there isn’t, we’ll still get a match (the empty String). And rather than come up with a complicated regular expression we’re just going to parse this string further manually. Essentially for any input that isn’t blank, we cut off the starting and ending parens, break it on ; (which may not exist), and figure out which one of those strings represents what we’re looking for. We parse that further by splitting it on , (if any), and trimming the results before turning it into a Set. Yes, this requires two passes and we could probably have done it with one pass, but I feel that this is easier to follow. Besides, we parse this once and never think about it again so there’s not much to gain here speed-wise. We’re trading a tiny bit of speed (maybe) for clarity (maybe?).

Fighting Logic

Now that we have a Group, we can determine how much damage is done when fighting each other.

// In Group, in Day24

fun calculateDamageTo(other: Group): Int =
    if (this.team == other.team) 0
    else effectivePower() * when (attackType) {
        in other.immuneTo -> 0
        in other.weakTo -> 2
        else -> 1
    }

I love that Kotlin lets us express this as one single expression. If the groups are on the same team, damage is zero. Otherwise we will multiply the effectivePower() by a force multiplier - 0x if the other group is immune to our attack, 2x if they are weak to our attack, and 1x if neither of those are true. Multiplying by 1x here is simpler than checking in an if and avoiding the multiplication. I love the power of when in Kotlin.

From there we can write the function that actually causes two groups to fight:

// In Group, in Day24

fun fight(other: Group): Int {
    val unitDeath = calculateDamageTo(other) / other.unitHp
    other.units -= unitDeath
    if (other.units <= 0) {
        other.alive = false
    }
    return unitDeath
}

In this function we calculate the damage we would do to other group and see how many of its units we have killed off. If they have zero or fewer units we mark them as no longer alive. And from this we return the number of unit deaths we’ve caused because we’ll need it later.

Target Selection

This is the part of the puzzle that gave me the most problems. We have to read the instructions carefully because the selection order is really important (sort of like on Day 15!). Let’s go over the logic for a single round of fighting.

// In Day24

private fun findTargets(combatants: List<Group>): Set<Pair<Group, Group?>> {
    val alreadyTargeted = mutableSetOf<Group>()
    return combatants.filter { it.alive }
        .sortedWith(compareByDescending<Group> { it.effectivePower() }.thenByDescending { it.initiative })
        .map { group ->
            group to combatants
                .filter { it.alive }   // Only fight the living
                .filterNot { group.team == it.team }  // Only fight the other team
                .filterNot { it in alreadyTargeted }  // Only fight somebody that isn't already a target
                .sortedWith(compareByDescending<Group> { group.calculateDamageTo(it) }.thenByDescending { it.effectivePower() }.thenByDescending { it.initiative })
                .filterNot { group.calculateDamageTo(it) == 0 }  // Remove any targets that we can't actually damage.
                .firstOrNull() // Account for the fact that we *may* not have a target
                .also {
                    // Add our selected target to the targeted list
                    if (it != null) alreadyTargeted.add(it)
                }
        }
        .toSet()
}

This function is used to have each Group pick its target. We return this data as a Set of Pairs, the second part of which is a nullable Group?. That’s because there may not be a target for each group. While picking, again, order is critical so we start by filtering out the dead groups and sorting them into the order in which they are allowed to pick opponents - by highest effective power, then by highest initiative.

Once our combatants are in order, we have them pick by going through all other fighters that are alive and on the other team. We again need to sort these potential targets by the amount of damage we can use them, then by their effective power (descending), and then by descending initiative. This section was the source of a few bugs for me, so be careful!

Then we filter out groups we can’t possibly damage, pick the best option if it exists, and add the target to the set we’re maintaining. That set is used to ensure that each group is only targeted at most once. Now we have our Pair<Group, Group?> and can repeat this for each of the living combatants.

Now we can use that data to actually fight one round!

// In Day24

private fun roundOfFighting(combatants: List<Group>): Int =
    findTargets(combatants)
        .sortedByDescending { it.first.initiative }
        .filterNot { it.second == null }
        .map { (attacker, target) ->
            if (attacker.alive) attacker.fight(target!!)
            else 0
        }
        .sum()

Again with the sorting. First we use the function we just wrote to find targets, order them by initiative, and then attack. In this function we record the total amount of unit deaths that occur in a single round. This is to catch when fighting stops. If we go an entire round without any deaths happening we know we’ve hit a point where we can’t possibly end the match. This will be important later, or for catching errors in our various sorts above (ask me how I know that).

The Battle

With our single round of fighting done, we can move on to the battle itself.

// In Day24

private fun fightBattle(combatants: List<Group>): List<Group> {
    var deaths: Int? = null
    while (deaths != 0) {
        deaths = roundOfFighting(combatants)
    }
    return combatants.filter { it.alive }
}

The battle will rage until no side scores a death against its opponent. This means it is truly over or can never end given the conditions. We loop through rounds over and over until this condition hits, and then return the list of survivors to the caller.

From there we can solve…

// In Day24

fun solvePart1(): Int =
    fightBattle(fighters).sumBy { it.units }

Star earned! Onward!

Day 24, Part 2

Things aren’t looking good for the reindeer. The man asks whether more milk and cookies would help you think.

If only you could give the reindeer’s immune system a boost, you might be able to change the outcome of the combat.

A boost is an integer increase in immune system units’ attack damage. For example, if you were to boost the above example’s immune system’s units by 1570, the armies would instead look like this:

Immune System:
17 units each with 5390 hit points (weak to radiation, bludgeoning) with
 an attack that does 6077 fire damage at initiative 2
989 units each with 1274 hit points (immune to fire; weak to bludgeoning,
 slashing) with an attack that does 1595 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

With this boost, the combat proceeds differently:

Immune System:
Group 2 contains 989 units
Group 1 contains 17 units
Infection:
Group 1 contains 801 units
Group 2 contains 4485 units

Infection group 1 would deal defending group 2 185832 damage
Infection group 1 would deal defending group 1 185832 damage
Infection group 2 would deal defending group 1 53820 damage
Immune System group 2 would deal defending group 1 1577455 damage
Immune System group 2 would deal defending group 2 1577455 damage
Immune System group 1 would deal defending group 2 206618 damage

Infection group 2 attacks defending group 1, killing 9 units
Immune System group 2 attacks defending group 1, killing 335 units
Immune System group 1 attacks defending group 2, killing 32 units
Infection group 1 attacks defending group 2, killing 84 units
Immune System:
Group 2 contains 905 units
Group 1 contains 8 units
Infection:
Group 1 contains 466 units
Group 2 contains 4453 units

Infection group 1 would deal defending group 2 108112 damage
Infection group 1 would deal defending group 1 108112 damage
Infection group 2 would deal defending group 1 53436 damage
Immune System group 2 would deal defending group 1 1443475 damage
Immune System group 2 would deal defending group 2 1443475 damage
Immune System group 1 would deal defending group 2 97232 damage

Infection group 2 attacks defending group 1, killing 8 units
Immune System group 2 attacks defending group 1, killing 306 units
Infection group 1 attacks defending group 2, killing 29 units
Immune System:
Group 2 contains 876 units
Infection:
Group 2 contains 4453 units
Group 1 contains 160 units

Infection group 2 would deal defending group 2 106872 damage
Immune System group 2 would deal defending group 2 1397220 damage
Immune System group 2 would deal defending group 1 1397220 damage

Infection group 2 attacks defending group 2, killing 83 units
Immune System group 2 attacks defending group 2, killing 427 units

After a few fights…

Immune System:
Group 2 contains 64 units
Infection:
Group 2 contains 214 units
Group 1 contains 19 units

Infection group 2 would deal defending group 2 5136 damage
Immune System group 2 would deal defending group 2 102080 damage
Immune System group 2 would deal defending group 1 102080 damage

Infection group 2 attacks defending group 2, killing 4 units
Immune System group 2 attacks defending group 2, killing 32 units
Immune System:
Group 2 contains 60 units
Infection:
Group 1 contains 19 units
Group 2 contains 182 units

Infection group 1 would deal defending group 2 4408 damage
Immune System group 2 would deal defending group 1 95700 damage
Immune System group 2 would deal defending group 2 95700 damage

Immune System group 2 attacks defending group 1, killing 19 units
Immune System:
Group 2 contains 60 units
Infection:
Group 2 contains 182 units

Infection group 2 would deal defending group 2 4368 damage
Immune System group 2 would deal defending group 2 95700 damage

Infection group 2 attacks defending group 2, killing 3 units
Immune System group 2 attacks defending group 2, killing 30 units

After a few more fights…

Immune System:
Group 2 contains 51 units
Infection:
Group 2 contains 40 units

Infection group 2 would deal defending group 2 960 damage
Immune System group 2 would deal defending group 2 81345 damage

Infection group 2 attacks defending group 2, killing 0 units
Immune System group 2 attacks defending group 2, killing 27 units
Immune System:
Group 2 contains 51 units
Infection:
Group 2 contains 13 units

Infection group 2 would deal defending group 2 312 damage
Immune System group 2 would deal defending group 2 81345 damage

Infection group 2 attacks defending group 2, killing 0 units
Immune System group 2 attacks defending group 2, killing 13 units
Immune System:
Group 2 contains 51 units
Infection:
No groups remain.

This boost would allow the immune system’s armies to win! It would be left with 51 units.

You don’t even know how you could boost the reindeer’s immune system or what effect it might have, so you need to be cautious and find the smallest boost that would allow the immune system to win.

How many units does the immune system have left after getting the smallest boost it needs to win?

Good news! We don’t have to write any more sorting code! We need to keep fighting battles until we can get the immune system to win. Unlike Day 15, we don’t have to have every group survive. Even if one unit in one group survives that’s a win.

First, let’s write some code to boost the immune system by a given amount:

// In Day24
private fun boostImmuneSystem(boost: Int): List<Group> =
    fighters.map {
        it.copy(unitDamage = it.unitDamage + if (it.team == Team.ImmuneSystem) boost else 0)
    }

Because we mutate the Groups, we need to copy them. If they are part of the immune system we need to apply the boost factor. Otherwise, we just copy it as-is. By coping from the list of fighters we parsed earlier we can fight battles without worrying about the state of our Groups, because we’ll get all new ones for the next battle.

And now we can solve…

// In Day24

fun solvePart2(): Int =
    generateSequence(1, Int::inc).mapNotNull { boost ->
        val combatants = fightBattle(boostImmuneSystem(boost))
        if (combatants.all { it.team == Team.ImmuneSystem }) combatants.sumBy { it.units }
        else null
    }.first()

We get to use generateSequence here, which will keep incrementing numbers until we return a non-null value. Inside the lambda, we boost up each of our combatants, and fight the battle. If the only survivors are part of the immune system, we add up their surviving units, otherwise we return null.

Star earned! One more day left…

Further Reading

  1. Index of All Solutions - All solutions for 2018, in Kotlin.
  2. My Github repo - Solutions and tests for each day.
  3. Solution - Full code for day 24
  4. Advent of Code - Come join in and do these challenges yourself!