Day 2 - Rock, Paper, Scissors
Subscribe to Monday Morning Haskell!
Problem Overview
In today's problem, we're playing Rock-Paper-Scissors (RPS). Given a series of rounds of RPS play, we're supposed to evaluate the total "score" we get depending on what we've played. Inputs are line-by-line with two characters on each line:
A Y
B X
C Z
The first character always tells us what the opponent plays in that match - A = Rock
, B = Paper
, C = Scissors
.
In the first part of the problem, the second character simply tells us what figure we should play (X = Rock
, Y = Paper
, Z = Scissors
). In the second part of the problem, this character actually tells us the result we are trying to achieve - X = Loss
, Y = Draw
, Z = Win
.
In both cases, our final solution is to calculate our total score over all the rounds, tabulated as follows:
- 6 points are given for a win, 3 for a draw, and 0 for a loss.
- Then we get a certain number of points for the figure we played - 1 for Rock, 2 for Paper, and 3 for Scissors.
So for part 1, the simple 3-line inputs gives the following results:
Round 1: Play paper (2 points) against rock. Win (6 points)
Round 2: Play rock (1 point) against paper. Lose (0 points)
Round 3: Play scissors (3 points) against scissors. Draw (3 points)
Adding up all the points gives a total of 15.
For part 2, we get the following sequence by trying to match the results:
Round 1: Draw (3 points) against rock by playing rock (1 point)
Round 2: Lose (0 points) against paper by playing rock (1 point)
Round 3: Win (6 points) against scissors by playing rock (1 point)
This gives a total of 12 points.
Solution Approach and Insights
This problem follows the simple and common "fold line-by-line" solution approach. I have some pre-planned boilerplate in my solution template for this! The folding action is not hard here - we just have to evaluate the result of the match and score it appropriately.
Parsing the Input
So remember, our sample input looks like this:
A Y
B X
C Z
I started with an RPS
type for the three possible figures we can playing:
data RPS = Rock | Paper | Scissors
deriving (Show, Eq)
So we parse one of the figures using alternatives:
parseRPS :: ParsecT Void Text m RPS
parseRPS = parseRock <|> parsePaper <|> parseScissors
where
parseRock = (char 'A' <|> char 'X') >> return Rock
parsePaper = (char 'B' <|> char 'Y') >> return Paper
parseScissors = (char 'C' <|> char 'Z') >> return Scissors
So we can parse a single line by taking two of these figures with a space between. In my template, I have a generic LineType
alias to use both while parsing and folding over lines. In our case, each line is two of these RPS
values.
type LineType = (RPS, RPS)
parseLine :: (MonadLogger m) => ParsecT Void Text m LineType
parseLine = do
first <- parseRPS
char ' '
second <- parseRPS
return (first, second)
Then our final input uses the very common sepEndBy1 ... eol
pattern. We use another alias for InputType
here.
type InputType = [LineType]
parseInput :: (MonadLogger m) => ParsecT Void Text m InputType
parseInput = sepEndBy1 parseLine eol
As a final wrinkle, we'll make a separate type for part 2, because the letters represent something semantically different. We won't change the parser. We'll just write a translation function to use later.
data ExpectedResult = Loss | Draw | Win
deriving (Show, Eq)
rpsToResult :: RPS -> ExpectedResult
rpsToResult Rock = Loss
rpsToResult Paper = Draw
rpsToResult Scissors = Win
translate :: (RPS, RPS) -> (RPS, ExpectedResult)
translate (first, second) = (first, rpsToResult second)
Getting the Easy Solution
As I mentioned above, this problem fits a common pattern: fold our inputs line-by-line and accumulate a solution. I'll use some more generic types and values to outline this approach.
solveFold :: (MonadLogger m) => [LineType] -> m EasySolutionType
solveFold = foldM foldLine initialFoldV
type FoldType = Int
initialFoldV :: FoldType
initialFoldV = 0
foldLine :: (MonadLogger m) => FoldType -> LineType -> m FoldType
foldLine = ...
We're tracking a score, so the FoldType
that we're modifying with each step is just an Int
, and we give the initial value of 0. Solving the problem is as simple as applying foldM
with a proper folding function and the initial value. The only challenge is filling in foldLine
. For this, we need two scoring functions, one for the figure we choose (scoreRps
) and another for the outcome of the match, which just requires looking at each case:
scoreRps :: RPS -> Int
scoreRps Rock = 1
scoreRps Paper = 2
scoreRps Scissors = 3
evalMatch :: (RPS, RPS) -> Int
evalMatch (Rock, Rock) = 3
evalMatch (Rock, Paper) = 6
evalMatch (Rock, Scissors) = 0
evalMatch (Paper, Rock) = 0
evalMatch (Paper, Paper) = 3
evalMatch (Paper, Scissors) = 6
evalMatch (Scissors, Rock) = 6
evalMatch (Scissors, Paper) = 0
evalMatch (Scissors, Scissors) = 3
And our fold simply applies both these to the input and adds to the previous result!
foldLine :: (MonadLogger m) => FoldType -> LineType -> m FoldType
foldLine prevScore (first, second) = return $ prevScore + (evalMatch (first, second) + scoreRps second)
Getting the "Hard" Solution
For part 2, all we really need is to translate each input pair so it has an ExpectedResult
, and then use a different evaluation function. Here's how we evaluate each pair:
evalMatchHard :: (RPS, ExpectedResult) -> Int
evalMatchHard (Rock, Win) = 8 -- Play Paper (2 + 6)
evalMatchHard (Rock, Draw) = 4 -- Play Rock (1 + 3)
evalMatchHard (Rock, Loss) = 3 -- Play Scissors (3 + 0)
evalMatchHard (Paper, Win) = 9 -- Play Scissors (3 + 6)
evalMatchHard (Paper, Draw) = 5 -- Play Paper (2 + 3)
evalMatchHard (Paper, Loss) = 1 -- Play Rock (1 + 0)
evalMatchHard (Scissors, Win) = 7 -- Play Rock (1 + 6)
evalMatchHard (Scissors, Draw) = 6 -- Play Scissors (3 + 3)
evalMatchHard (Scissors, Loss) = 2 -- Play Paper (2 + 0)
And we fold over the inputs like so:
type HardSolutionType = EasySolutionType -- < Int
processInputHard :: (MonadLogger m) => InputType -> m HardSolutionType
processInputHard inputs = foldM foldExpResult initialFoldV (map translate inputs)
foldExpResult :: (MonadLogger m) => Int -> (RPS, ExpectedResult) -> m Int
foldExpResult prevScore (oppPlay, result) = return $ prevScore + evalMatchHard (oppPlay, result)
Answering the Question
No further work is needed beyond passing our inputs to our functions and taking the result:
solveEasy :: FilePath -> IO (Maybe Int)
solveEasy fp = runStdoutLoggingT $ do
input <- parseFile parseInput fp
Just <$> processInputEasy input
solveHard :: FilePath -> IO (Maybe Int)
solveHard fp = runStdoutLoggingT $ do
input <- parseFile parseInput fp
Just <$> processInputHard input
-- Note: These functions are the same, since nothing extra was required in processing!
processInputEasy = solveFold
Now we're done!