CSP Combination Lock
This example implements the simplest (and most insecure) of combination locks. It demonstrates the use of event channels and governing them all by a timer.
It would probably be a good idea to have a look at the introductory demo before looking at this one. Some of the concepts are pretty close to the same and I won't go as much into them here.
The problem
Quite simply, we have three buttons (marked A, B, and C). There is a single combination of these buttons that will solve the combination (spoiler alert: it's a Genesis song). From the time we push the first button, we have only five seconds to complete the combination.
If we get a button wrong, we can continue until the timer runs out, but we have to start over with our button clicks. If we get the right combination, we want to know right away, rather than having to wait for our five seconds to expire.
The solution
We can do this with channels and a process, which probably makes sense given that this is an example in a CSP library. Here's the working example; after that, we'll go over how it works.
(In case you don't know any Genesis songs, the combination is A B A C A B.)
The channels
Channels are used to communicate the events from the buttons being clicked to any process that's interested in knowing about such a thing. We make this happen by using a function that we used in the intro, which causes an event firing on a particular DOM element to put the event objects on a channel.
function listen(el, type, ch = chan()) {
el.addEventListener(type, event => putAsync(ch, event));
return ch;
}
This function takes a DOM element and a type of event (in our case, since we're looking for click events, that type is 'click'
). It then either uses the channel we pass in as the third parameter or, if there is no third parameter, creates a new channel. Every time the specified event occurs on the specified DOM element, the event object will be put onto this channel.
Thing is, we're not really interested in anything about this event object. We're only interested in knowing which button was clicked. There are a few ways to do this. We could check the target
property of the event object. We could use the channel
property of the object returned by alts
to figure out which channel unblocked the process, knowing that each channel is assigned to one button. Or we could actually manipulate the channel to return a value that tells us which channel it is. We'll use the latter because it's a pretty elegant solution.
We don't want to change our code in listen
, because it's a generic and highly useful function that we can use over and over again (it was used twice in the three intro examples, for instance). Instead, we'll use the ability of cispy channels to use the transformer functions that come out of a transducer library (there are a few good ones, but you may want to go with xduce). We'll use the map
transformer to turn that event object into something we can use better. This is exactly what we did in the third intro example, except this time we don't want coordinates. We want an identifier for the button.
const A = Symbol();
const B = Symbol();
const C = Symbol();
function constantly(x) {
return () => x;
}
const chA = listen(byId('button-a'), 'click', chan(1, { transducer: map(constantly(A)) }));
const chB = listen(byId('button-b'), 'click', chan(1, { transducer: map(constantly(B)) }));
const chC = listen(byId('button-c'), 'click', chan(1, { transducer: map(constantly(C)) }));
There are three things to look at here, conveniently separated by blank lines.
The first section creates the identifiers that we want to use for the buttons, given the same name as the buttons themselves. We use symbols here because they are unique. There can never be another symbol created that is the same (from a ===
perspective) as A
, for instance. So we can check to see if something is equal to A
, and if it is, we know that it is A
.
Symbols are a feature of ES2015 and should be universally supported in modern browsers. Their use doesn't seem to be widespread yet, however. You could just as easily assign {}
to each of these three variables and it would work fine.
The second section is the constantly
function. This function creates another function that always returns the same value, using the same trick as with location
in the third intro example (generating functions based on a parameter rather than having to write different functions for each value of that parameter we need). The upshot is that if we use one of these functions in map
, every value taken from the channel will be whatever is passed to constantly
, no matter what value was actually put onto the channel.
The last section takes advantage of this. A map
transformer that uses a constantly
function that always returns the appropriate button's identifier is attached to each event channel that's created by listen
. That means that when the A button is clicked, A
will appear on chA
, and so on.
(A quick note: button-a
, etc. are the ID's of the three buttons in the combination example, and byId
is just a utility function that gets the DOM element with that ID. So byId('button-a')
just returns the DOM element for the first button. See the source code if you're interested.)
The process
Now that we have our channels set up, we can create the process that will be listening to them.
const MAX_TIME = 5000;
const COMBINATION = [A, B, A, C, A, B];
go(async () => {
let clicks = [];
let chZ = chan();
while (true) {
const alt = await alts([chA, chB, chC, chZ]);
clicks.push(alt.value);
...
}
});
Aside from the constants set up at the beginning (for the amount of time the user has to get the combination right and for the combination itself), the rest of this should look very familiar. It's the setting of some state variables to initial values and then a while
loop with an alts
invocation right at the beginning. That's exactly the way the last two intro examples were set up, and it's a recurring pattern when using CSP to manage events.
Perhaps the most interesting thing in this snippet is that a local channel (chZ
) is being checked by alts
as well. Since the channel is local, no other process can access and therefore put onto it. Why have it at all? We'll discover that a little later.
In the intro examples with event handling, the bulk of the while
loop was an if/else
statement that performed some action based on what came back from the await alts
. Our while
loop body here is no different.
if (alt.channel === chZ) {
setStatus("You're not fast enough, try again!");
clicks = [];
chZ = chan();
}
The first branch executes if the channel that caused await alts
to unblock was the mysterious channel Z. We still don't know what might be on that channel, but we get some sort of clue from the fact that the displayed message tells the user that he's run out of time (setStatus
is another utility function, one that sets the text in the example's div). All of the state variables are set back to their initial states as well, indicating that the user is able to start again.
else if (alt.value === COMBINATION[clicks.length - 1]) {
if (clicks.length === COMBINATION.length) {
setStatus("Combination unlocked!");
clicks = [];
chZ = chan();
} else {
setStatus(clicks);
if (!chZ.timeout) {
chZ = timeout(MAX_TIME);
}
}
}
The next branch executes if the value returned from await alts
matches the value at the same index of the combination. In other words, it's called if the user selects the correct next button. What happens then depends on whether this was the last button (the length of our clicks
array, which tracks the buttons that have been clicked so far, is the same as the length of the combination).
If it was, it means the user must have entered the correct combination. If so, a message is displayed to say so, and the state variables are set back to their initial values. Just like in the first branch. Again this means that the user may start again.
Far more interesting is what happens if this was not the last button (fewer buttons have been clicked than the length of the combination). Most of the time, we just update the div with all of the buttons that have been clicked so far, and let the while
loop continue. But if the timeout
property of chZ
is false, we reassign chZ
to something we have not seen before - a timeout channel.
The timeout
function is offered by Cispy, and it creates a new unbuffered channel, just like chan()
. The difference is that this channel will automatically close after a delay, specified in milliseconds in the timeout
call itself. So now our private, local channel Z turns out to have something to do after all - it can sit around and then close after 5 seconds (5000 milliseconds, the value of MAX_TIME
).
How does this help? Well, it turns out that await alts
doesn't only unblock if a value is put onto one if its channels. It also unblocks if one of its channels closes. So now we can see that 5 seconds after the first button is pushed, chZ
will close, which causes await alts
to unblock and set alt.channel
to chZ
. Which executes the first branch, telling the user that he's failed. The only way this is averted is by getting the right combination, which resets chZ
back to a normal, non-timeout channel, meaning that it again sits there and does nothing in the next await alts
call.
By the way, both await put
and await take
share this behavior, unblocking if the channel passed to them closes.
For the sake of completeness, let's look at the last branch of the if/else
.
else {
setStatus("Wrong combination, try again");
if (!chZ.timeout) {
chZ = timeout(MAX_TIME);
}
clicks = [];
}
This branch executes if a button was pushed (alt.channel
is not chZ
) and if that button does not match the next button in the combination. In this case the user is shown a message that the wrong button was clicked, and the list of clicked buttons is cleared (the user has to start over). Channel Z is not reset though; it continues to tick away towards that 5 seconds. Of course, like in the last branch, if this is the first button that's clicked the timeout
channel is created to start the countdown.
In the end, if a channel is a timeout channel, it has its timeout
property set to true
, while a regular channel will have a timeout
property value of false
. Because of that, we're able to use the channel itself as a state variable in our local event loop. If chZ.timeout === false
then the timer must not be running, so when the first button is clicked we will reassign chZ
to be a timeout channel. If we do something to finish the combination, either by entering it successfully or taking too long to do so, chZ
gets reassigned back to a regular channel. Thus we can always tell whether the proverbial clock is ticking by checking the value of chZ.timeout
.