Advent of Code 2022, but in JS and point-free style: Day 2

Piotr Jaworski
7 min readDec 6, 2022

--

Photo by Elena Mozhvilo on Unsplash

The complete list of articles up to date:

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 a truthy 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!

--

--

No responses yet