engine.js | |
---|---|
The quiz running engine (business logic) | var Quiz = require('./models/quiz');
var Question = require('./models/question');
var Answer = require('./models/answer');
var redis = require('redis').createClient();
var _ = require('underscore');
var events = require('events');
var async = require('async');
var colors = require('colors');
var moment = require('moment');
var Promise = require('promise'); |
Persistence keys in the Redis store | var AUTH_PERSIST_KEY = 'blend-demo:ips-to-users';
var CUR_QUESTION_KEY = 'blend-demo:current-question';
var PLAYERS_KEY = 'blend-demo:players';
var SCOREBOARD_KEY = 'blend-demo:score-board';
var USER_LIST_KEY = 'blend-demo:users'; |
The Engine singleton | |
This contains all the business logic for running the quiz; other parts of the app (admin pages and player-facing pages) end up calling its methods, and it emits events for various stages of the game. | var Engine = _.extend(new events.EventEmitter(), {
currentQuiz: null,
currentQuestion: null,
currentQuestionExpiresAt: 0,
currentQuestionTimer: null,
playerCount: 'Aucun joueur',
startedAt: 0, |
Authentication middleware helper | |
This is called by the front pages to try and get the currently-logged user back from the request's session or, failing that, from the Redis-backed IP-to-player mapping, so we don't have to re-auth with Twitter between two server starts. This is especially useful during dev, when the server auto-restarts at every code change. | checkAuth: function(req, res, next) {
var user = req.user;
var self = this;
if (user) {
handleUser(user);
} else {
redis.hget(AUTH_PERSIST_KEY, req.ip, handleRedisUser);
}
function handleUser(user) {
var json = JSON.stringify(user), origScore; |
Notice the use of | async.waterfall([ |
1: persist the current user in the IP-to-player map | function(cb) { redis.hset(AUTH_PERSIST_KEY, req.ip, json, cb); }, |
| function(foo, cb) { redis.zscore(USER_LIST_KEY, json, cb); },
function(score, cb) { redis.zadd(USER_LIST_KEY, (origScore = score) || Date.now(), json, cb); }, |
| function(foo, cb) { redis.zcard(USER_LIST_KEY, cb); },
function(count, cb) {
self.playerCount = count <= 0 ? 'Aucun joueur' : (1 == count ? 'Un joueur' : count + ' joueurs');
if (self.currentQuiz && !self.isRunning() && !origScore)
self.emit('quiz-join', user, self.playerCount);
cb();
}, |
This is a middleware: don't forget to pass on control to the remainder of the stack once we're done. | next
]);
} |
Tiny callback when our user wasn't found in the session and we looked them up in the Redis store. | function handleRedisUser(err, json) {
if (!json)
res.redirect(302, '/front/auth');
else {
req.user = JSON.parse(json);
handleUser(req.user);
}
}
}, |
Scoreboard computation on quiz end | |
This computes the final scoreboard for the quiz once it's done, and persists it into Redis so we can call it up whenever we want. This sorts players by descending total score. Ex-aequos are still ranked separately (just being lazy here) in no particular order amongst them. | computeScoreboard: function computeScoreboard(cb) {
var self = this, players; |
| async.waterfall([ |
| function(cb) { redis.hgetall(PLAYERS_KEY, cb); }, |
| function(list, cb) {
players = sortPlayerList(list);
cb();
}, |
| this.getUsers,
function(users, cb) {
_.each(players, function(p, i) {
var matchingUser = _.findWhere(users, { id: p.id });
players[i] = _.extend(p, matchingUser);
});
redis.set(SCOREBOARD_KEY, JSON.stringify(players), cb);
}, |
| function() { cb(null, players); }
]);
}, |
Stats computation on question end | |
When a question is done, we compute basic stats about it: what the correct answers were, what percentages of answering players selected each answer, what the overall correct answers ratio was, and who the current leading players are. All percentages are rounded to the nearest integer. | computeStats: function computeStats(pairs, cb) { |
Gotta love Underscore. | var correctStatuses = _.pluck(this.currentQuestion.answers, 'correct'); |
For every answer, determine how many players selected it. Players can select multiple answers. This is based on the final state of play once the question has timed out, as players can adjust their answers until then. | var answerSpreads = {};
_.each(pairs, function(record) {
record.answerIds.forEach(function(id) {
id = +id;
answerSpreads[id] = (answerSpreads[id] || 0) + 1;
});
}); |
Turn that list of raw counters into count+percentage pairs. If no player participated we'll get a weird rounding due to 0/0, so let's workaround this by setting a floor of 1. | var playerCount = Math.max(_.size(pairs), 1);
answerSpreads = this.currentQuestion.answers.map(function(a) {
var count = answerSpreads[a.id] || 0;
return { count: count, percent: Math.round(count * 100 / playerCount) };
}); |
Determine overall counts (and %) of fully-correct players | var correctCount = _.where(pairs, { currentQuestionCorrect: true }).length;
var correctPercent = Math.round(correctCount * 100 / playerCount); |
Get the top 5 scores, and pick 10 random players inside that score range to report as the currently-leading players. | var sortedPlayers = sortPlayerList(pairs);
var minimumScore = sortedPlayers.length ? Math.max(sortedPlayers[0].score - 4, 1) : 1;
var top5Candidates = _.filter(sortedPlayers, function(p) { return p.score >= minimumScore; });
var random10BestIds = _.chain(top5Candidates).sample(10).pluck('id').value();
var random10Bests; |
Again, | async.waterfall([ |
| this.getUsers,
function(users, cb) {
random10Bests = _.filter(users, function(u) { return _.contains(random10BestIds, u.id); });
cb();
}, |
| function(cb) {
var str = 'Answers: ';
_.each(answerSpreads, function(spread, index) { |
Question indices are turned into letters (A, B, etc.). Correct answers are suffixed with a star. | str += String.fromCharCode(index + 65);
str += (correctStatuses[index] ? '*' : ' ');
str += ': ' + spread.count + ' (' + spread.percent + '%)';
if (index < answerSpreads.length - 1)
str += ' / ';
});
log('debug', str);
log('debug', correctCount + ' correct answers total (' + correctPercent + '%)');
log('debug', 'Random 10 Best: ' + _.pluck(random10Bests, 'name').join(', '));
cb();
}, |
| function() {
cb(null, {
correctCount: correctCount,
correctPercent: correctPercent,
spreads: answerSpreads,
statuses: correctStatuses,
random10Bests: random10Bests
});
}
]);
}, |
Just an accessor to gain access to the latest scoreboard, thanks to Redis storage. | getLatestScoreboard: function getLatestScoreboard(cb) {
redis.get(SCOREBOARD_KEY, function(err, json) {
if (err) throw err;
cb(null, JSON.parse(json));
});
}, |
A simple accessor to get the full list of players, least-recent first. Note that we let Redis maintain that sort order for us as we store in a sorted set with scores based on join time. | getUsers: function getUsers(cb) {
redis.zrangebyscore(USER_LIST_KEY, 0, +Infinity, function(err, users) {
cb(null, _.map(users, JSON.parse));
});
}, |
Entry point for answers by players. The passed | handleAnswer: function handleAnswer(answer) {
if (!this.isRunning())
return; |
Make sure we have actual Numbers in there, not just Strings.
| answer.answerIds = _.map(answer.answerIds, Number);
var self = this;
redis.hget(PLAYERS_KEY, answer.userId, function(err, record) {
if (err) throw err; |
Redis stores values as strings: parse the JSON out of it, if present (further down, we'll JSONify back before re-storing). | record = record ? JSON.parse(record) : { score: 0 };
var firstTimeAnswer = record.currentQuestionId !== self.currentQuestion.id;
record.currentQuestionId = self.currentQuestion.id;
record.answerIds = answer.answerIds; |
Update the "current answer set is correct" flag on the fly. | record.currentQuestionCorrect = self.currentQuestion.checkAnswers(answer.answerIds);
redis.hset(PLAYERS_KEY, answer.userId, JSON.stringify(record)); |
Notify the system that an answer was given/updated, and pass individual answer flags along in case listeners react differently based on selected answer items. Also pass the answerer's ID, just in case. | var bools = _.map(self.currentQuestion.answers, function(a) { return _.contains(answer.answerIds, a.id); });
self.emit(firstTimeAnswer ? 'new-answer' : 'edit-answer', +answer.userId, bools);
});
}, |
Quiz activation/initialization | initQuiz: function initQuiz(quizId) {
var self = this;
self.reset('quiz'); |
In case we got passed a descriptor object, just get its | if (quizId.id)
quizId = quizId.id; |
Returning a | return Quiz.find(quizId).success(function(quiz) {
self.currentQuiz = quiz;
log('info', 'Quiz inits: ' + quiz.title); |
Initializing a quiz resets quiz-state storage, so all players start anew. | redis.del(PLAYERS_KEY, function(err) {
if (err) throw err;
self.emit('quiz-init', quiz);
});
});
}, |
Simple helper to tell whether a quiz is currently going on (not just init'd, but started). | isRunning: function isRunning() { return !!this.currentQuestion; }, |
Quiz stepping | |
This is the core step-ahead mechanism for a quiz. When a quiz starts, it
actually ends up delegating to this. This returns a promise that calling code
can chain against / wait for to be sure that either the next question is up, or
the quiz is done, except if the quiz isn't even started (returns | nextQuestion: function nextQuestion() { |
Not started? Forget it! | if (0 === this.startedAt)
return; |
Next-question selector logic. We build up selecting options for the ORM. | var opts = { where: { visible: true }, order: 'questions.position, answers.position',
limit: 1, include: [Answer] }; |
If we run on a randomized quiz, starting it picked a random, one-time ordering
of the questions and stored it in | if (this.questionIds) {
if (!this.questionIds.length) {
return this.wrapUp();
}
opts.where['questions.id'] = this.questionIds.shift();
} else if (this.currentQuestion) {
opts.where['questions.position'] = { gt: this.currentQuestion.position };
}
var self = this; |
Here's our promise result, with a built-in success handler chained in. | return this.currentQuiz.getQuestions(opts).success(function(qs) {
var question = qs[0];
if (question) { |
There IS a next question matching our criteria? Awesome, adjust state, persist in Redis and get on with it! | self.currentQuestion = question;
self.currentQuestionExpiresAt = Date.now() + question.duration * 1000;
self.currentQuestion.expiresAt = self.currentQuestionExpiresAt;
self.currentQuestionTimer = setTimeout(self.questionExpires, question.duration * 1000);
self.currentQuestionInterval = setInterval(self.questionProgresses, 1000);
redis.hmset(CUR_QUESTION_KEY, {
id: self.currentQuestion.id,
remaining: question.duration * 1000
});
log('info', 'Question starts: ' + question.title + ' (' + question.duration + 's)');
self.emit('question-start', question, self.currentQuestionExpiresAt);
} else { |
There ISN'T any question left for our criteria: the quiz is done, wrap it up. | self.wrapUp();
}
}).error(function(res) {
console.error('SQL ERROR:', res);
});
}, |
Expiry handler bound to a timeout in | questionExpires: function questionExpires() {
clearInterval(this.currentQuestionInterval);
clearTimeout(this.currentQuestionTimer);
redis.del(CUR_QUESTION_KEY);
var self = this;
async.waterfall([
this.updatePlayerScores,
this.computeStats,
function(stats) {
log('info', 'Question ends!');
self.emit('question-end', stats, self.currentQuiz.id);
}
]);
}, |
A simple every-second handler that just persists time passing in Redis for potential fault tolerance and logs question progress. | questionProgresses: function questionProgresses() {
var remaining = this.currentQuestionExpiresAt - Date.now();
redis.hset(CUR_QUESTION_KEY, 'remaining', remaining);
log('debug', 'Question has only ' + (remaining / 1000) + 's remaining');
}, |
A convenience state resetter for quiz and question starts. | reset: function reset(mode) {
if ('quiz' == mode) {
this.currentQuiz = null;
this.startedAt = 0;
redis.del(PLAYERS_KEY);
}
clearTimeout(this.currentQuestionTimer);
delete this.questionIds;
this.currentQuestion = this.currentQuestionTimer = null;
this.currentQuestionExpiresAt = 0;
return this;
}, |
Quiz start | |
Once a quiz, post-init, has garnered enough players, we can officially start it.
For randomized quizzes, this defines a one-shot, random ordering of questions.
Then this delegates to | start: function start(callback) {
this.startedAt = Date.now();
this.reset('question');
if ('random' !== this.currentQuiz.runningMode) {
log('info', 'Quiz starts (sequential)');
return this.nextQuestion();
}
var self = this; |
Notice the two chained | return this.currentQuiz.getQuestions({ where: { visible: true } }).then(function(qs) { |
Gotta love Underscore… | self.questionIds = _.chain(qs).pluck('id').shuffle().value();
log('info', 'Quiz starts (randomized to ' + self.questionIds.join() + ')');
}).then(self.nextQuestion);
}, |
A convenience method called when a question ends, to increment the scores of every player that was eventually correct, and persist the updated scores in Redis. | updatePlayerScores: function updatePlayerScores(cb) {
redis.hgetall(PLAYERS_KEY, function(err, pairs) {
if (err) throw err;
_.each(pairs, function(rec, userId) {
pairs[userId] = rec = JSON.parse(rec);
if (rec.currentQuestionCorrect) {
++rec.score;
redis.hset(PLAYERS_KEY, userId, JSON.stringify(rec));
}
});
log('debug', 'Player scores updated');
cb(null, pairs);
});
}, |
Quiz end | |
This is called from | wrapUp: function wrapUp() {
var self = this; |
This is the one time we explictly create a Promise. Because Sequelize already uses
node-promise, we stay with this, but this
code would have been a bit simpler with the | return new Promise(function(resolve) {
self.computeScoreboard(function(err, scoreboard) {
if (err) throw err;
self.reset('quiz');
self.emit('quiz-end', scoreboard);
resolve();
});
});
}
}); |
We log in color using not actual color names but semantic names defined in our theme: this is where we map to supported color codes. | colors.setTheme({
debug: 'blue',
error: 'red',
info: 'green',
warn: 'yellow'
}); |
A convenience timestamped logger method used throughout the engine code. | function log(level, message) {
message = '*** [' + moment().format('HH:mm:ss') + '] ' + message;
(console[level] || console.log)(message[level]);
} |
A convenience method taking a Redis-issued hash of | function sortPlayerList(pairs) { |
This is a massive example of Underscore's power. We let it operate in sequence on | return _.chain(pairs)
.map(function(rec, userId) {
if (_.isString(rec))
rec = JSON.parse(rec);
return { id: userId, score: rec.score };
})
.sortBy(function(r) { return -r.score; })
.value();
} |
The engine code often passes methods of the | _.bindAll(Engine, 'computeStats', 'getUsers', 'handleAnswer', 'nextQuestion',
'questionExpires', 'questionProgresses', 'updatePlayerScores');
module.exports = Engine;
|