Advent of Code 2022 - Day 2: Rock Paper Scissors
It's Day 2 of Advent of Code, and we're apparently going to play in a tournament of Rock Paper Scissors. One elf has "helpfully" given us a strategy guide to guarantee a win. Now, I am adamantly opposed to cheating in any form, so this puzzle posed a predicament. Luckily our task sidesteps this; rather than using the strategy, we only care to check to see how effective it would be.
OpenAI recently released ChatGPT, an interactive language model for dialogues. It is built with some functionality to refuse inappropriate requests. Trending on HackerNews today is a post describing all of the way people have found to circumvent that filter. One of the most popular is to phrase the prompt as make believe. So that's what we're doing today. We're not cheating at Rock Paper Scissors; we're simply playing a character that would cheat at Rock Paper Scissors.
A Naive, But Solid Approach
The scoring for a single round of Rock Paper Scissors is based on two simple rules. One of them is dependent on the shape you throw (3 options), and the other is dependent on the outcome of the game (shapes you and your opponent throw, 3 * 3 == 9
options). That's not that many cases! Let's just hardcode them.
module AOC2022.Day02 (spec) where
import Control.Applicative
import Input
import Test.Hspec
input :: IO String
input = readDay 2022 2
spec :: IO ()
spec = hspec $ do
describe "Day 2" $ do
it "solves part 1" $ do
myInput <- input
part1 myInput `shouldBe` 0 -- redacted
it "solves part 2" $ do
myInput <- input
part2 myInput `shouldBe` 0 -- redacted
part1 :: String -> Int
part1 = sum . fmap score . lines
score :: String -> Int
score = liftA2 (+) scoreShape scoreOutcome
scoreShape :: String -> Int
scoreShape [_, _, 'X'] = 1
scoreShape [_, _, 'Y'] = 2
scoreShape [_, _, 'Z'] = 3
scoreShape _ = 0
scoreOutcome :: String -> Int
scoreOutcome "A X" = 3
scoreOutcome "A Y" = 6
scoreOutcome "A Z" = 0
scoreOutcome "B X" = 0
scoreOutcome "B Y" = 3
scoreOutcome "B Z" = 6
scoreOutcome "C X" = 6
scoreOutcome "C Y" = 0
scoreOutcome "C Z" = 3
scoreOutcome _ = 0
part2 :: String -> Int
part2 = sum . fmap score2 . lines
score2 :: String -> Int
score2 = liftA2 (+) scoreShape2 scoreOutcome2
scoreShape2 :: String -> Int
scoreShape2 "A X" = 3
scoreShape2 "A Y" = 1
scoreShape2 "A Z" = 2
scoreShape2 "B X" = 1
scoreShape2 "B Y" = 2
scoreShape2 "B Z" = 3
scoreShape2 "C X" = 2
scoreShape2 "C Y" = 3
scoreShape2 "C Z" = 1
scoreShape2 _ = 0
scoreOutcome2 :: String -> Int
scoreOutcome2 [_, _, 'X'] = 0
scoreOutcome2 [_, _, 'Y'] = 3
scoreOutcome2 [_, _, 'Z'] = 6
scoreOutcome2 _ = 0
OK, so it's a little long, but I doubt there is a simpler, more understandable approach. The only real trick is the use of liftA2
to convert +
from an operator on numbers to an operator on functions. For further reading, check out Learn You A Haskell's chapter on Applicative Functors, especially regarding instance Functor ((->) r)
.
Who Needs Simple?
While the code is working just fine, there is something deeply unsatisfying about such repetitive statements. My goal isn't to strictly minimize the amount of code, though, so to justify further exploration let's introduce a self-imposed "Part 3": make it work with Rock Paper Scissors Lizard Spock, an extension of Rock Paper Scissors with 5 options.
The key insight is that all games like Rock Paper Scissors Lizard Spock can be represented as a cyclic order. For any game with 2k+1
shapes, each shape has k
other shapes that beat it and k
other shapes that lose to it. For code golfers, this opens up a plethora of modular arithmetic tricks to aid in solving. We'll focus more on the algebraic side of things.
So let's start with a data type that implements Ord:
data Shape = Rock | Paper | Scissors deriving (Eq, Show)
instance Ord Shape where
compare x y =
let minmax = [Rock, Scissors]
in if x `elem` minmax && y `elem` minmax then compare y x else compare x y```
This compare function is no good! While it may work for our needs, it is not a valid instance of the Ord typeclass, since it is not transitive. (Rock is less than Paper, and Paper is less than Scissors, but Rock is not less than Scissors.)
So we throw that away and create our own typeclass CyclicEnum, using the same code to implement a cycle compare, or ccompare
. Given a Shape, we also want to be able to find the Shape that is some number of steps down the cycle, so we introduce another function toCyclicEnumFrom
.
class (Bounded a, Ord a, Enum a) => CyclicEnum a where
ccompare :: a -> a -> Ordering
toCyclicEnumFrom :: a -> Int -> a
ccompare x y =
let minmax = [minBound, maxBound]
in if x `elem` minmax && y `elem` minmax then compare y x else compare x y
toCyclicEnumFrom a i = toEnum $ (i + fromEnum a + sizeof a) `mod` sizeof a
sizeof :: CyclicEnum a => a -> Int
sizeof a = 1 + val maxBound - val minBound
where
val = fromEnum . (`asTypeOf` a)
With this new algebraic representation, we unfortunately have to parse the strings. Let's assume that in our Rock Paper Scissors Lizard Spock, example, we would use A, B, C, D, and E as well as V, W, X, Y, and Z. We can do some modular arithmetic on the character codes to generalize this:
parseCode :: CyclicEnum a => Char -> a
parseCode = toCyclicEnumFrom minBound . clampAlphabet . fromEnum
where
-- A, B, C -> 0, 1, 2 and X, Y, Z -> -3, -2, -1
clampAlphabet = (`subtract` 13) . (`mod` 26) . (+ 13) . (`subtract` fromEnum 'A')
With our new representation, we can rephrase our scoring function in terms of the underlying enum values. We can also implement the requested behavior from Part 2, to decode the outcome of the game into its actual shapes, using our handy toCyclicEnumFrom
function:
decodeGame :: CyclicEnum a => (a, a) -> (a, a)
decodeGame (a, o) = (a, toCyclicEnumFrom a (fromEnum o - sizeof a `div` 2))
Putting It All Together
Here's the full code. Implementing Rock Paper Scissors Lizard Spock is now as simple as adding two new items to the Shape enum!
The proper order of the cycle according to the usual rules would be Rock-Spock-Paper-Lizard-Scissors. Each shape defeats the two preceding items and loses to the two following shapes.
class (Bounded a, Ord a, Enum a) => CyclicEnum a where
ccompare :: a -> a -> Ordering
toCyclicEnumFrom :: a -> Int -> a
ccompare x y =
let minmax = [minBound, maxBound]
in if x `elem` minmax && y `elem` minmax then compare y x else compare x y
toCyclicEnumFrom a i = toEnum $ (i + fromEnum a + sizeof a) `mod` sizeof a
sizeof :: CyclicEnum a => a -> Int
sizeof a = 1 + val maxBound - val minBound
where
val = fromEnum . (`asTypeOf` a)
data Shape = Rock | Paper | Scissors deriving (Eq, Show, Ord, Bounded, Enum)
instance CyclicEnum Shape
parseCode :: CyclicEnum a => Char -> a
parseCode = toCyclicEnumFrom minBound . clampAlphabet . fromEnum
where
-- A, B, C -> 0, 1, 2 and X, Y, Z -> -3, -2, -1
clampAlphabet = (`subtract` 13) . (`mod` 26) . (+ 13) . (`subtract` fromEnum 'A')
parseGame :: String -> (Shape, Shape)
parseGame [a, ' ', x] = (parseCode a, parseCode x)
scoreShape :: CyclicEnum a => a -> a -> Int
scoreShape = const $ (1 +) . fromEnum
scoreOutcome :: CyclicEnum a => a -> a -> Int
scoreOutcome they you = (3 *) $ fromEnum $ ccompare you they
score :: CyclicEnum a => (a, a) -> Int
score = liftA2 (+) (uncurry scoreShape) (uncurry scoreOutcome)
decodeGame :: CyclicEnum a => (a, a) -> (a, a)
decodeGame (a, o) = (a, toCyclicEnumFrom a (fromEnum o - sizeof a `div` 2))
part1 :: String -> Int
part1 = sum . fmap (score . parseGame) . lines
part2 :: String -> Int
part2 = sum . fmap (score . decodeGame . parseGame) . lines
Advent of Code 2022 Series
This post is part of a series describing my Haskell solutions to Advent of Code 2022.
Next: Day 3: Rock Paper Scissors Previous: Day 1: Calorie Counting
Cheers!