Advent of Code 2021 day 21

    Day 21 wasn't too bad in theory, but a couple of annoying bugs made it took longer than it should. I thought I'd talk about both the solution and a quick look at debugging Haskell.

    Part 1

    The first question for me was how to represent a game state and a game as a whole. I considered using a State monad, but the part 1 puzzle was very similar to day 14 and day 17, in that an iterate over a step-update function would be enough to produce the answer. That suggested that a direct representation of the game would be enough for the moment. (I use a custom Show instance for compactness).

    data Player = Player1 | Player2 deriving (Eq, Ord, Show)
    
    data PlayerState = PlayerState 
      { position :: Int
      , score :: Int
      } deriving (Eq, Ord, Show)
    
    data Game = Game 
      { players :: M.Map Player PlayerState
      , current :: Player
      , rolls :: Int
      } deriving (Eq, Ord)
    
    instance Show Game where
      show game = "{" ++ (showPlayer Player1) ++ (showActive) ++ (showPlayer Player2) ++ "}"
        where showPlayer p = (show $ position $ (players game) ! p) ++ "," ++ (show $ score $ (players game) ! p) 
              showActive = if (current game) == Player1 then "<" else ">"
    

    I guessed that part 2 of the problem would change the dice generation function, so I made the dice stream an input into the game simulation. That changed the iteration function from iterate to scanl'.

    part1 game = scoreGame finalGame
      where finalGame = head $ dropWhile (not . finished 1000) $ scanl' gameStep game detDice
            detDice = map (\n -> sum ([d `mod1` 100 | d <- [n..(n+2)]]::[Int])) [1, 4..]
    

    I also created a couple of utility functions. mod1 is like mod, but keeps a number in the range [1, n] rather than [0, n-1].

    finished :: Int -> Game -> Bool
    finished threshold game = any (>= threshold) $ map score $ M.elems (players game)
    
    scoreGame :: Game -> Int
    scoreGame game = (rolls game) * losingScore
      where losingScore = minimum $ map score $ M.elems (players game)
    
    nextPlayer :: Player -> Player
    nextPlayer Player1 = Player2
    nextPlayer Player2 = Player1
    
    mod1 :: Int -> Int -> Int
    mod1 a b = ((a - 1) `mod` b) + 1
    

    The gameStep function directly implements the rules of the game, as specified.

    gameStep :: Game -> Int -> Game
    gameStep game theseRolls = game'
      where activePlayer = (players game) ! (current game)
            pos = position activePlayer
            sc = score activePlayer
            pos' = (pos + theseRolls) `mod1` 10
            sc' = sc + pos'
            activePlayer' = PlayerState {position = pos', score = sc'}
            current' = nextPlayer (current game)
            players' = M.insert (current game) activePlayer' (players game)
            game' = Game { players = players'
                         , current = current'
                         , rolls = rolls game + 3
                         }
    

    Part 2

    For part 2, I had to count all the possible histories that led to every finishing move. I decided to do that by maintaining a multiset of games, where each element of the multiset counted the number of histories that led to it. For example, this generates the set of dice rolls.

    type Games = MS.MultiSet Game
    type Dice = MS.MultiSet Int
    type Winners = MS.MultiSet Player
    
    diracDice :: Dice
    diracDice = MS.fromList [a + b + c | a <- [1..3], b <- [1..3], c <- [1..3]]
    

    Imperatively, the simulation algorithm is:

    For each ongoing game:
    For each dice roll:
    Update this game with this dice roll
    Record the number of histories as (number of games) × (number of dice rolls)

    In a more functional style, both these loops are folds: combining a collection of items (each game, each dice roll) into a single value (the new multiset of histories).  nonDetGameStep and nonDetGameStep1 represent the two loops above, while nonDetGameStep2 does the update within the inner loop. The new set of histories being built up is held in an accumulator, called acc. nonDetGameSimulation stops simulating when there are no longer any possible games, and also handles extracting the counts of winners.

    nonDetGameSimulation :: Int -> Games -> Dice -> Winners -> Winners
    nonDetGameSimulation winThreshold games0 dice winners0 
      | MS.null games0 = winners0
      | otherwise = nonDetGameSimulation winThreshold games dice winners
      where games' = nonDetGameStep games0 dice
            (winGames, games) = MS.partition (finished winThreshold) games'
            p1Wins = MS.size $ MS.filter (\g -> current g == Player2) winGames
            p2Wins = MS.size $ MS.filter (\g -> current g == Player1) winGames
            winners = MS.insertMany Player2 p2Wins $ MS.insertMany Player1 p1Wins winners0
    
    nonDetGameStep :: Games -> Dice -> Games
    nonDetGameStep games dice = MS.foldOccur (nonDetGameStep1 dice) MS.empty games
    
    nonDetGameStep1 :: Dice -> Game -> MS.Occur -> Games -> Games
    nonDetGameStep1 dice game gnum acc = MS.foldOccur (nonDetGameStep2 game gnum) acc dice
    
    nonDetGameStep2 :: Game -> MS.Occur -> Int -> MS.Occur -> Games -> Games
    nonDetGameStep2 game gnum roll dnum acc = MS.insertMany game' (gnum * dnum) acc
      where game' = gameStep game roll
    

    This was the correct approach, but a couple of typos meant it didn't work at first, giving much lower values than in the problem description.

    Debugging

    A problem with debugging Haskell is getting access to all the current values in a running program. The usual imperative approach, of littering code with print statements, doesn't work when code is outside the IO monad.

    The Debug.Trace.trace function provides much the same effect, and that was all I needed for this problem.

    The first problem was threading the accumulator through the folds. My original version of nonDetGameStep1 read as:

    nonDetGameStep1 dice game gnum acc = MS.foldOccur (nonDetGameStep2 game gnum) MS.empty dice
    

    which meant that nonDetGameStep2 only ran once for each call of nonDetGameSimulation. That was because Haskell was bright enough to realise that only the last-visited member of games would contribute to the new history, as the accumulator was reset to MS.empty each previous step.

    With that solved, I got a much better answer bit it was still too small. A comparison with another solution on the solutions megathread revealed that my code wasn't including the last generation of game histories. That was caused by a typo in nonDetGameSimulation:

    nonDetGameSimulation :: Int -> Games -> Dice -> Winners -> Winners
    nonDetGameSimulation winThreshold games0 dice winners0 
      
      | MS.null games = winners0         -- <<<< Typo here
      
      | otherwise = nonDetGameSimulation winThreshold games dice winners
      where games' = nonDetGameStep games0 dice
            (winGames, games) = MS.partition (finished winThreshold) games'
            p1Wins = MS.size $ MS.filter (\g -> current g == Player2) winGames
            p2Wins = MS.size $ MS.filter (\g -> current g == Player1) winGames
            winners = MS.insertMany Player2 p2Wins $ MS.insertMany Player1 p1Wins winners0
    

    The typo terminated the simulation when the next generation had no more games.

    Fixing both of those gave the correct solution!

    Code

    You can get the code from my locally-hosed Git repo, or from Gitlab.

    Neil Smith

    Read more posts by this author.