In my previous article, we counted the calories of foods carried by Elves. We used nothing but Ramda.js and tried to stick to the point-free style as much as possible. Little did I know how easy this was compared to the second day! Do you want to learn why? Please continue reading.
The second task of this year’s Advent of Code seems pretty straightforward. Given a set of moves in a series of rock-paper-scissor games, we must evaluate the outcome of performing those moves according to specific scoring rules. Let’s start with preparing the data and the function pipeline, just as the previous time:
import R from "ramda";
import data from "./data.txt";
R.pipe(
console.log
)(data);
This time, we get a long, multiline string of pairs of letters. So far, so good. Let’s split the string into an array of individual lines:
import R from "ramda";
import data from "./data.txt";
R.pipe(
R.split("\n"),
console.log
)(data);
And further on, the individual lines into arrays of letters:
import R from "ramda";
import data from "./data.txt";
R.pipe(
R.split("\n"),
R.map(R.split(" ")),
console.log
)(data);
Again, nothing intimidating. Next up, let’s assign values to the letters. To make it simpler, we’re going to assign the letters the same values they would receive according to the scoring rules, regardless of who performed the move.
import R from "ramda";
import data from "./data.txt";
const MOVE_VALUES = {
A: 1,
B: 2,
C: 3,
X: 1,
Y: 2,
Z: 3
};
R.pipe(
R.split("\n"),
R.map(R.split(" ")),
R.map(R.map((move) => R.prop(move, MOVE_VALUES))),
console.log
)(data);
To get the value of a move (represented as a letter), we need to have the values mapped in the first place — hence the MOVE_VALUES
constant. Accessing it is pretty simple — we can utilize the prop
function, which is useful to access objects (or arrays) by a string representation of the desired property.
Now for the tricky part: the scoring. This is going to be a big one (cue the Michael Scott from the Office jokes):
import R from "ramda";
import data from "./data.txt";
const MOVE_VALUES = {
A: 1,
B: 2,
C: 3,
X: 1,
Y: 2,
Z: 3
};
const moduloThree = R.flip(R.uncurryN(2, R.modulo))(3);
const applyModuloToIndex = (index) => R.over(R.lensIndex(index), moduloThree);
const comparatorTransformFunctions = [
R.identity,
applyModuloToIndex(0),
applyModuloToIndex(1),
R.map(moduloThree)
];
const compareMoves = (difference) =>
R.anyPass(
R.map((fn) => R.pipe(fn, R.apply(R.subtract), R.equals(difference)))(
comparatorTransformFunctions
)
);
const [
isFirstMoveHigher,
isSecondMoveHigher,
areMovesEqual
] = R.map(compareMoves, [1, -1, 0]);
const evaluateMoves = R.cond([
[areMovesEqual, R.always(3)],
[isSecondMoveHigher, R.always(6)],
[isFirstMoveHigher, R.always(0)]
]);
R.pipe(
R.split("\n"),
R.map(R.split(" ")),
R.map(R.map((move) => R.prop(move, MOVE_VALUES))),
R.map(R.pipe(R.juxt([R.last, evaluateMoves]), R.sum)),
console.log
)(data);
Well, that escalated quickly. Let’s analyze it step by step. Let’s start with the function call that we added to the main pipeline:
R.map(R.pipe(R.juxt([R.last, evaluateMoves]), R.sum)),
The map
and pipe
calls should be pretty obvious by now — we use map
to iterate over all the move pairs, and pipe
because we’re going to call more than one function at once. Let’s see what happens inside the pipe
call. First up, there’s juxt
. This is a very handy function indeed —it works kind of like a reversed map — instead of accepting a single function and a list of values, and then calling the function while iterating over the values, juxt
accepts a list of functions and a single value and returns an array of the same length as the array of functions, with each function called with the same (single) value.
How is that useful? Let’s look at the requirements. For each move pair, we need to calculate two values — the first one is the value of our move, and the second is the value based on the outcome of a single round. So, based on one input, we need to have two values — juxt
fits in perfectly, calling two functions over the same value. The first provided function is straightforward — it’s the last member of the moves array — since we get points from the move that we chose. The second one is called evaluateMoves
:
const evaluateMoves = R.cond([
[areMovesEqual, R.always(3)],
[isSecondMoveHigher, R.always(6)],
[isFirstMoveHigher, R.always(0)]
]);
This is the most beautiful side of the point-free style — when written properly, it reads as plain English, thus eliminating the need for comments. We only need to know what the Ramda functions do:
cond
works pretty much like a switch-case statement; it accepts an array of arrays of functions; if the first function in the array returns atruthy
value, the second function is called.always
always (sic) return the same value — its initial argument.
We can plainly see that if the moves are equal, the score is equal to 3. And so on. Let’s see how the moves are compared. Let’s start from the top of the complete listing. To properly compare moves, we’re going to need to sometimes perform the modulo
operation. Let’s prepare a function for that:
const moduloThree = R.flip(R.uncurryN(2, R.modulo))(3);
What happened here? The original Ramda’s modulo
function is actually data-first. What it means, is that we can’t do something like:
const moduloThree = R.modulo(3);
because the divisor is passed as the second argument, not the first one. We’d need to do it like this:
const moduloThree = (dividend) => R.modulo(dividend, 3);
Alternatively, we can flip the order of arguments (for which we’re making use of the flip
function), but first, we need to uncurry
the original modulo
function, for which we also need to provide its arity (which is equal to 2). After that, since flip
returns a curried function, we can immediately call it with the divisor that we need (which is three).
Next, we have another useful function:
onst applyModuloToIndex = (index) => R.over(R.lensIndex(index), moduloThree);
What it does is it applies our moduloThree
function to a chosen index in an array. We utilize the over
function, which calls a function using a supplied lens, and lensIndex
creates a lens based on the supplied index. Let's see how this is used:
const comparatorTransformFunctions = [
R.identity,
applyModuloToIndex(0),
applyModuloToIndex(1),
R.map(moduloThree)
];
Here we create an array of all the combinations of the functions that we’re going to need to compare the moves properly. Why do we need that? Rock has a value of 1, Scissors has a value of 3, and Rock beats Scissors, right? So we need to cater to that. We can transform both of the values by modulo of three and check which one is higher, but that won’t cover the scenario of Paper vs. Scissors. What we can do, is we can check by the difference of one in four different scenarios — with no modulos, both values with modulo applied and with modulo applied to either one. That’s why we need four transform functions. Let’s see how they are used:
const compareMoves = (difference) =>
R.anyPass(
R.map((fn) => R.pipe(fn, R.apply(R.subtract), R.equals(difference)))(
comparatorTransformFunctions
)
);
We use the anyPass
function, which takes an array of functions and returns true
if any of them evaluates to true
. We can take our four transformation functions and generate four evaluation functions from there. For each transformation scenario, we apply
the subtract
function to the pair of move values, and after that, we check if the difference equals
to the given one. Let’s move forward and see how it’s used:
const [
isFirstMoveHigher,
isSecondMoveHigher,
areMovesEqual
] = R.map(compareMoves, [1, -1, 0]);
This is pretty straightforward — if the difference is equal to 1, the first move from the pair wins; if -1 — the second one wins. If the difference is equal to zero, there’s a tie. Now we can use those in the evaluateMoves
function, thus completing this step.
Oof, that part was a mouthful (cue another portion of Michael Scott jokes). If you have any questions, please post them in the comments! But we still have one more thing to do. In this line:
R.map(R.pipe(R.juxt([R.last, evaluateMoves]), R.sum)),
we calculated both the move and the outcome values and added them. But we now have a very long array of scores for individual moves! Let’s sum them up quickly:
import R from "ramda";
import data from "./data.txt";
const MOVE_VALUES = {
A: 1,
B: 2,
C: 3,
X: 1,
Y: 2,
Z: 3
};
const moduloThree = R.flip(R.uncurryN(2, R.modulo))(3);
const applyModuloToIndex = (index) => R.over(R.lensIndex(index), moduloThree);
const comparatorTransformFunctions = [
R.identity,
applyModuloToIndex(0),
applyModuloToIndex(1),
R.map(moduloThree)
];
const compareMoves = (difference) =>
R.anyPass(
R.map((fn) => R.pipe(fn, R.apply(R.subtract), R.equals(difference)))(
comparatorTransformFunctions
)
);
const [
isFirstMoveHigher,
isSecondMoveHigher,
areMovesEqual
] = R.map(compareMoves, [1, -1, 0]);
const evaluateMoves = R.cond([
[areMovesEqual, R.always(3)],
[isSecondMoveHigher, R.always(6)],
[isFirstMoveHigher, R.always(0)]
]);
R.pipe(
R.split("\n"),
R.map(R.split(" ")),
R.map(R.map((move) => R.prop(move, MOVE_VALUES))),
R.map(R.pipe(R.juxt([R.last, evaluateMoves]), R.sum)),
R.sum,
console.log
)(data);
And that’s it! This time the code (apart from providing the answer to Day 2 of 2022’s Advent of Code) doesn’t look like a Christmas Tree, but it sure is as packed with goodies as I’d like my presents to be!
A working example can be found in this CodeSandbox. Follow me for articles covering the next days! Also — that’s only the first half of the solution — share yours in the comments!