Advent of Code 2022 day 2

    Day 2 and we're already into some deeper software engineering principles! To wit,

    1. Parse, don't validate
    2. Grow the language to the domain, not lower the domain to the language


    The domain of this problem is the game Rock-Paper-Scissors, but represented as pairs of letters. But because we're talking about the game, I'll represent things in terms of the game and concepts that make sense in that domain.

    That gives me a bunch of new data structures to represent games and rounds.

    data Shape = Rock | Paper | Scissors deriving (Show, Eq, Ord, Enum)
    data Result = Loss | Draw | Win deriving (Show, Eq, Ord, Enum)
    data Round = Round Shape Shape deriving (Eq, Show)

    This means I can't represent something that isn't a game, even though there are many text strings that aren't valid games. This is the advantage of parsing, not validating.

    Then I can encode the core rule of the game as a function:

    player2Result :: Round -> Result
    player2Result (Round Rock Paper) = Win
    player2Result (Round Paper Scissors) = Win
    player2Result (Round Scissors Rock) = Win
    player2Result (Round x y) | x == y = Draw
    player2Result _ = Loss

    The same with the instructions of how to score the round:

    scoreRound :: Round -> Int
    scoreRound r@(Round _ y) = scoreShape y + scoreResult (player2Result r)
    scoreShape :: Shape -> Int
    scoreShape s = 1 + fromEnum s
    scoreResult :: Result -> Int
    scoreResult r = 3 * fromEnum r

    The final part is to convert the input text into this representation. Enter the parser:

    match1P = roundP `sepBy` endOfLine
    roundP = Round <$> p1ShapeP <*> (" " *> p2ShapeP)
    p1ShapeP = aP <|> bP <|> cP
    aP = Rock <$ "A"
    bP = Paper <$ "B"
    cP = Scissors <$ "C"
    p2ShapeP = xP <|> yP <|> zP
    xP = Rock <$ "X"
    yP = Paper <$ "Y"
    zP = Scissors <$ "Z"

    If the parser succeeds, I can be confident that I only have valid rounds to work with.

    Part 1

    Getting the input right makes the actual solution embarrassingly simple:

    main :: IO ()
    main = 
      do  dataFileName <- getDataFileName
          text <- TIO.readFile dataFileName
          let match1 = successfulParse1 text
          print $ part1 match1
    part1 :: [Round] -> Int
    part1 = sum . fmap scoreRound

    Score each round and add up the results.

    Part 2

    With part 2, the input now means something different, so I need to represent and parse it differently. As each input line is now a Shape and a Result, I combine them in a ShapeResult. I also need a new parser to process this new representation.

    data ShapeResult = ShapeResult Shape Result deriving (Eq, Show)
    match2P = shapeResultP `sepBy` endOfLine
    shapeResultP = ShapeResult <$> p1ShapeP <*> (" " *> resultP)
    resultP = xrP <|> yrP <|> zrP
    xrP = Loss <$ "X"
    yrP = Draw <$ "Y"
    zrP = Win <$ "Z"

    Now to find the defined move for player 2.

    I could explicitly write the move required for each shape/result combination, but that would mean another place where the rules of the game are encoded. On the other hand, I should use those rules to express the idea of part 2: "figure out what shape to choose so the round ends as indicated."

    I'll try all the shapes until I get the Round I need.

    roundFromResult :: ShapeResult -> Round
    roundFromResult (ShapeResult shape result) = Round shape p2s
      where p2s = head [ p2Shape 
                       | p2Shape <- [Rock .. Scissors]
                       , player2Result (Round shape p2Shape) == result

    This is the idea of growing the language to meet the problem, which I first met explicitly in the book On Lisp. (But don't pay too much attention to Graham's more recent utterings.)

    And that gives another very simple solution for part 2:

    part2 = sum . fmap (scoreRound . roundFromResult)


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

    Neil Smith

    Read more posts by this author.