diff --git a/lib/routes.js b/lib/routes.js index 4d06fa6..3fab0c7 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -66,8 +66,6 @@ module.exports = function (app) { var gamedata = games.join(pin, { session: {} }, { ua: req.headers['user-agent'] }); - - console.log('-------------------'); console.log(gamedata.game.players); @@ -115,62 +113,81 @@ module.exports = function (app) { res.send(true); }); + /** + * Status: start a new game or hang an xhr until two players have joined. + * + * If there's no pin present (or no game for the given pin) then a new game is + * created by getPin, then retrieved by get(pin), and then the current user + * joins that new game. + * + * The pin is saves in req.session.pin and req.session.ingame, and the xhr + * is closed with a 'start' event. + * + * -- + * + * If there is a pin and matching game, it will do one of two things... + * + * If the game is in one of a few states (isn't the FSM supposed to manage + * that stuff?) then it must be ready to go, so close the xhr right away (via + * ready()), passing back various bits of data. + * + * If it's not yet ready, (ie, waiting), then we wait on the FSM changing + * state before closing the XHR. + */ app.get('/status/:pin?', function (req, res, next) { var pin = req.params.pin, game = games.get(req.pin); - if (req.xhr) { - if (pin && game) { - var ready = function () { - var turn = game.isCurrentPlayer(req.session.playerId), - otherLetter = req.session.playerId === 'a' ? 'b' : 'a', - me = { - letter: req.session.playerId, - score: game.getPlayerByLetter(req.session.playerId).score - }, - them = { - letter: otherLetter, - score: game.getPlayerByLetter(otherLetter).score - }; - - res.send({ - type: 'ready', - data: { - towin: game.towin, - gameover: game.state === 'gameover', - me: me, - them: them, - currentPlayer: turn ? req.session.playerId : otherLetter, - turn: req.session.debug ? true : turn - } - }); - }; - - if (game.state === 'ready' || game.state === 'playing' || game.state === 'gameover') { - ready(); - } else if (game.state === 'waiting') { - console.log('waiting for game to start'); - - // FIXME this event isn't being picked up at all - game.on('state.ready', ready); - } - } else { - // start a game - pin = req.session.pin = games.getPin(req.session); - - game = games.get(pin); - var gamedata = games.join(pin, req, { ua: req.headers['user-agent'] }); + console.log(pin, game); + // console.log('xhr?', req.xhr); + // if (!req.xhr) return next(); + if (pin && game) { + var ready = function () { + var turn = game.isCurrentPlayer(req.session.playerId), + otherLetter = req.session.playerId === 'a' ? 'b' : 'a', + me = { + letter: req.session.playerId, + score: game.getPlayerByLetter(req.session.playerId).score + }, + them = { + letter: otherLetter, + score: game.getPlayerByLetter(otherLetter).score + }; - req.session.playerId = gamedata.playerId; - req.session.ingame = pin; res.send({ - type: 'start', + type: 'ready', data: { - pin: pin + towin: game.towin, + gameover: game.state === 'gameover', + me: me, + them: them, + currentPlayer: turn ? req.session.playerId : otherLetter, + turn: req.session.debug ? true : turn } }); + }; + + if (game.state === 'ready' || game.state === 'playing' || game.state === 'gameover') { + ready(); + } else if (game.state === 'waiting') { + console.log(pin, 'waiting for game to start'); + + game.on('state.ready', ready); } } else { - next(); + // start a game + pin = req.session.pin = games.getPin(req.session); + + game = games.get(pin); + var gamedata = games.join(pin, req, { ua: req.headers['user-agent'] }); + + req.session.playerId = gamedata.playerId; + req.session.ingame = pin; + res.send({ + type: 'start', + data: { + pin: pin + } + }); } }); @@ -193,12 +210,17 @@ module.exports = function (app) { var result = false; if (req.session.ingame) { // player should leave this game first - var game = games.get(req.session.ingame); - game.leave(game.getOtherPlayer(req.session.playerId).id); + var game = games.get(req.session.ingame), + player = game.getOtherPlayer(req.session.playerId); + if (player) game.leave(player.id); } res.send(result); }); + /** + * Join a game. Hit when join form is submitted. This may create the game if + * it does not exist. + */ app.post('/join', function (req, res) { // TODO test if there's an active game under that pin, // if so, allow join and remove from cache @@ -222,27 +244,33 @@ module.exports = function (app) { req.session.ingame = pin; if (req.xhr) { - res.send(true); - } else { - res.redirect('/play'); - } - } else if (game) { - console.log('failed joining - game may be full'); - if (req.xhr) { - res.send(false); - } else { - res.render('join', { - 'message': "That game is full!" - }); + return res.send(true); } - } else if (req.xhr) { - console.log('no game data'); + return res.redirect('/play'); + } + + // I don't think this can happen, becuase in the case of a falsey game, a + // new one is created (above) + console.log('failed joining - game may be full'); + if (req.xhr) { res.send(false); } else { - res.redirect('/join/' + pin); + res.render('join', { + 'message': "That game is full!" + }); } }); + /** + * All roads lead to the player view. + * + * Headshot is almost a single page app, but with server state and + * code injection. + * + * All scripts are injected simultanously and manage their own initialisation. + * Mostly it's in start.js where getState conspires with init to pick what to + * show to the user. + */ app.get(['/play', '/join', '/start', '/join/:pin?', '/start/:pin?'], function (req, res, next) { var debug = req.session.debug || false; diff --git a/package.json b/package.json index 5467d5a..a93e8e3 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ }, "main": "server.js", "scripts": { - "start": "server.js" + "start": "server.js", + "test": "tap ./test" }, "repository": { "type": "git", @@ -37,6 +38,9 @@ "async": "~0.1.22", "grunt-contrib-concat": "~0.1.2", "grunt-contrib-jshint": "~0.1.1", - "grunt-contrib-uglify": "~0.1.1" + "grunt-contrib-uglify": "~0.1.1", + "tap": "~0.4.3", + "request": "~2.21.0", + "xmlhttprequest": "~1.5.0" } } diff --git a/public/js/main.js b/public/js/main.js index 15fbc29..9ec2f5d 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -18,6 +18,7 @@ var websocket = { event: 'receive_chat_msg' }; + var dataChannel = { send: function(message) { for (var connection in rtc.dataChannels) { @@ -169,7 +170,7 @@ $.on('pinchange', function () { rtc.connect((proto.indexOf('https') !== -1 ? 'wss:' : 'ws:') + href.substring(proto.length).split('#')[0], pin); }); -// should/could be done with bitwise state checking, +// should/could be done with bitwise state checking, // but it's late :( var state = { readyremote: false, diff --git a/public/js/start.js b/public/js/start.js index 6382127..2c3f5b5 100644 --- a/public/js/start.js +++ b/public/js/start.js @@ -17,43 +17,53 @@ turnEl.on('animationend', removeTurnClass); var timer = null, title = document.title; +/** + * Status opens an xhr to /status or /status/:pin. + * + * This will: + * - if there's no pin + * - create a game and re-request /status/:pin + * - otherwise + * - hang until another player joins and the xhr closes + * - then: + * - the URL is updated + * - user's score and turn bindings are set up + * - the game ('play' in game.js) is init'd + */ function status(callback) { + callback = callback || function () {}; clearTimeout(timer); xhr.get('/status/' + (pin ? pin : ''), function (err, result) { if (err) { console.log(this); timer = setTimeout(status, 5000); } - if (result) { - if (result.type === 'ready') { - window.history.replaceState({}, title, '/play'); - window.game = new Bind(result.data, { - 'me.score': '#myscore', - 'them.score': function (value) { - $.trigger('theirScore', value); - }, - 'turn': function (myturn) { - turnEl.dataset.turn = myturn; - $.trigger('myturn', myturn); - } - }); - towin = window.game.towin; - play.init(); - if (!ready) { - waiting[0].hidden = false; + if (!result) return callback(); + if (result.type === 'ready') { + window.history.replaceState({}, title, '/play'); + window.game = new Bind(result.data, { + 'me.score': '#myscore', + 'them.score': function (value) { + $.trigger('theirScore', value); + }, + 'turn': function (myturn) { + turnEl.dataset.turn = myturn; + $.trigger('myturn', myturn); } - - // this is done after play.init to ensure the event order is right - } else if (result.type === 'start') { - setPin(result.data.pin); - window.history.replaceState({}, title, '/start/' + pin); - status(); + }); + towin = window.game.towin; + play.init(); + if (!ready) { + waiting[0].hidden = false; } - } - if (callback) { - callback(); + // this is done after play.init to ensure the event order is right + } else if (result.type === 'start') { + setPin(result.data.pin); + window.history.replaceState({}, title, '/start/' + pin); + status(); } + callback(); }); } @@ -126,6 +136,12 @@ function tap(el, handler) { el.on('click', handler, false); } +/** + * Extract state and pin from the URL. + * + * `pin` is in the global scope, so this just modifies that, and returns the + * state, which is the first part of the URL. + */ function getState() { var l = window.location, state = 'join', @@ -157,6 +173,9 @@ function getState() { return state; } +/** + * Decide how load the app. Initialiased based on the state from getState. + */ function init(state) { console.log(state, pin); if (state === 'start' || (state === 'join' && pin)) { diff --git a/test/fixtures.js b/test/fixtures.js new file mode 100644 index 0000000..f46b769 --- /dev/null +++ b/test/fixtures.js @@ -0,0 +1,74 @@ +var request = require('request'), + _ = require('underscore'); + +var fix = { + mock: { + req: { + session: {}, + params: [], + body: {} + }, + user: { + ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_3) AppleWebKit/537.31 (KHTML, like Gecko) Chrome/26.0.1410.65 Safari/537.31' + } + } +}; + +/** + * Useful user object for testing endpoints. + * + * Create like so: + * var user = Object.create(fix.user).init(); + */ +fix.user = { + + /** + * Setup up user object + */ + init: function (opts) { + this.jar = request.jar(); + _.extend(this, opts); + return this; + }, + + /** + * Visit a given URL, passing options directly to request. + * + * user.visit('/fish', function (err, res, body) { . . . }); + * user.visit('/fish', { json: true }, function (err, res, body) { . . . }); + */ + visit: function (url, opts, cb) { + if (typeof opts === 'function') { cb = opts; opts = {}; } + var options = _.defaults({}, opts, { + url: url, + jar: this.jar + });; + return request(options, cb); + }, + + /** + * Simulate an XHR to the given URL. Assumes JSON will be returned, but that + * can be overridden in the options. + * + * user.xhr('/status', function (err, res, body) { . . . }); + * user.xhr('/text', { json: false }, function (err, res, body) { . . . }); + */ + xhr: function (url, opts, cb) { + if (typeof opts === 'function') { cb = opts; opts = {}; } + if (typeof opts.json === "undefined") opts.json = true; + var options = _.defaults({}, opts, { + json: true, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }); + return this.visit.call(this, url, options, cb); + } + +}; + +fix.createNewUser = function (options) { + return Object.create(fix.user).init(options); +} + +module.exports = fix; \ No newline at end of file diff --git a/test/game_test.js b/test/game_test.js new file mode 100644 index 0000000..018bcb8 --- /dev/null +++ b/test/game_test.js @@ -0,0 +1,93 @@ +var games = require('../lib/game'), + t = require('tap'), + fix = require('./fixtures'); + +console.log(games); + +t.test('creating', function (t) { + + t.test('create with a fresh pin creates a game', function (t) { + var game = games.create(1111); + console.log(game); + t.ok(game, 'Returned game is truthy.'); + t.equal(game.pin, 1111, 'Returned pin is the same.'); + t.end(); + }); + + t.end(); +}); + +t.test('join', function (t) { + + t.test('joining non-existant game fails', function (t) { + var game = games.join(2222, fix.mock.req, {}); + t.notOk(game, 'Game is falsy.'); + t.end(); + }); + + t.test('joining a waiting game works', function (t) { + var createdGame = games.create(3333); + var game = games.join(3333, fix.mock.req, {}); + t.ok(game, 'Game is truthy.'); + t.equal(game.game.pin, 3333, 'Game pin matches.'); + t.equal(game.game, createdGame, 'Game matches.'); + t.end(); + }); + + t.test('leaving a waiting game resets it', function (t) { + var game = games.create(5556); + var firstId = game.join(fix.mock.user); + t.equal(game.state, 'waiting', 'State updated to waiting.'); + game.leave(); + t.equal(game.players.length, 0, 'Players empty.'); + t.end(); + }); + + t.test('2 people joining a waiting game transitions to playing', function (t) { + var game = games.create(4444); + var firstId = game.join(fix.mock.user); + t.equal(firstId, 'a', 'id for first user is a'); + var secondId = game.join(fix.mock.user); + t.equal(secondId, 'b', 'id for second user is b'); + t.equal(game.state, 'playing', 'State updated to playing.'); + t.end(); + }); + + t.test('game can be played until gameover', function (t) { + var game = games.create(4445), + firstId = game.join(fix.mock.user), + secondId = game.join(fix.mock.user); + game.playturn({ playerId: 'a' }); + game.playturn({ playerId: 'b' }); + game.playturn({ playerId: 'a' }); + game.playturn({ playerId: 'b' }); + game.playturn({ playerId: 'a' }); + game.playturn({ playerId: 'b' }); + t.equal(game.state, 'gameover', 'State updated to gameover.'); + t.end(); + }); + + t.test('resetting transitions to waiting', function (t) { + var game = games.create(5555); + var firstId = game.join(fix.mock.user), + secondId = game.join(fix.mock.user); + t.equal(game.state, 'playing', 'State updated to playing.'); + game.reset(); + t.equal(game.state, 'waiting', 'State updated to waiting.'); + t.end(); + }); + + t.test('leaving a joined transitions to waiting', function (t) { + var game = games.create(6666); + var firstId = game.join(fix.mock.user), + secondId = game.join(fix.mock.user); + t.equal(game.state, 'playing', 'State updated to playing.'); + game.leave('a'); + t.equal(game.state, 'waiting', 'State updated to waiting.'); + t.equal(game.players.length, 1, 'One player left.'); + t.end(); + }); + + t.end(); +}); + diff --git a/test/status_test.js b/test/status_test.js new file mode 100644 index 0000000..3dfde22 --- /dev/null +++ b/test/status_test.js @@ -0,0 +1,321 @@ +var t = require('tap'), + fix = require('./fixtures'), + games = require('../lib/game'), + async = require('async'), + request = require('request'), + base = 'http://localhost:8080'; + +var XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest; + +t.test('server', function (t) { + t.test('is started', function (t) { + request({ + url: base, + jar: request.jar() + }, function (err, res, body) { + if (err || !res) t.fail('Server not started. ' + err); + else t.pass('Server started.'); + t.end(); + }); + }); +}); + +t.test('status', function (t) { + + t.test('creates a new game', function (t) { + var user = fix.createNewUser(); + user.xhr(base + '/status', + function (err, res, body) { + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + t.ok(body, 'Got a body.'); + t.equal(body.type, 'start', 'Got a body.'); + t.ok(body.data, 'Got some data.'); + t.ok(body.data.pin, 'Got a pin.'); + t.end(); + }); + }); + + t.test('hangs when pin is supplied', function (t) { + var hangingRequest, + cancelled = false, + user = fix.createNewUser(); + user.xhr(base + '/status', + function (err, res, body) { + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + // Hanging with pin + hangingRequest = user + .xhr(base + '/status/' + body.data.pin, + function (err, res, body) { + // If the connection is manually killed, don't error. + if (cancelled) return; + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + t.fail('Status with pin returned'); + t.end(); + }); + }); + setTimeout(function () { + t.pass('Timed out.'); + cancelled = true; + hangingRequest.req.end(); + hangingRequest.req.destroy(); + t.end(); + }, 1000); + }); + + t.test('game starts when 2 players join', function (t) { + var hangingRequest, + cancelled = false, + player1 = fix.createNewUser(), + player2 = fix.createNewUser(); + player1.xhr(base + '/status', function (err, res, body) { + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + + var pin = body.data.pin; + + async.parallel({ + one: function (done) { + + /** + * This simulates player who clicked 'New Game' + */ + player1.xhr(base + '/status/' + pin, + function (err, res, body) { + console.log('player 1 status:', body); + if (err || !body) { return done('Failed to join'); } + t.pass('IT GOT HERE!'); + done(null, body.data); + }); + + }, + two: function (done) { + + // This simulates player who clicked 'Join Game' and entered `pin` + setTimeout(function () { + var opts = { + method: 'POST', + body: { pin: pin } + }; + player2. xhr(base + '/join', opts, + function (err, res, body) { + if (err || !body) { return done('Failed to join'); } + player2 + .xhr(base + '/status/' + pin, + function (err, res, body) { + if (err || !body) { return done('Failed to get status.'); } + t.ok(body, 'Got a body.'); + t.equal(body.type, 'ready', 'Got a body.'); + t.ok(body.data, 'Got some data.'); + t.ok(body.data.towin, 'Got to windata.'); + done(null, body.data); + }); + }); + + }, 300); + + } + }, function (err, players) { + if (err) return t.fail(err); + t.ok(players.one.me.letter !== players.two.me.letter, + 'Players are serverd different letters'); + t.equal(players.one.currentPlayer, players.two.currentPlayer, + 'Both are sent the same current player.'); + t.end(); + }); + + }); + }); + + t.test('game starts when 2 players join with very close timings', function (t) { + var hangingRequest, + cancelled = false, + player1 = fix.createNewUser(), + player2 = fix.createNewUser(); + player1.xhr(base + '/status', function (err, res, body) { + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + + var pin = body.data.pin; + + async.parallel({ + one: function (done) { + + /** + * This simulates player who clicked 'New Game' + */ + player1.xhr(base + '/status/' + pin, + function (err, res, body) { + console.log('player 1 status:', body); + if (err || !body) { return done('Failed to join'); } + t.pass('IT GOT HERE!'); + done(null, body.data); + }); + + }, + two: function (done) { + + // This simulates player who clicked 'Join Game' and entered `pin` + setTimeout(function () { + var opts = { + method: 'POST', + body: { pin: pin } + }; + player2.xhr(base + '/join', opts, + function (err, res, body) { + if (err || !body) { return done('Failed to join'); } + player2.xhr(base + '/status/' + pin, + function (err, res, body) { + if (err || !body) { return done('Failed to get status.'); } + t.ok(body, 'Got a body.'); + t.equal(body.type, 'ready', 'Got a body.'); + t.ok(body.data, 'Got some data.'); + t.ok(body.data.towin, 'Got to windata.'); + done(null, body.data); + }); + }); + + }, 50); + + } + }, function (err, players) { + if (err) return t.fail(err); + t.ok(players.one.me.letter !== players.two.me.letter, + 'Players are serverd different letters'); + t.equal(players.one.currentPlayer, players.two.currentPlayer, + 'Both are sent the same current player.'); + t.end(); + }); + + }); + }); + + t.test('player joins then leaves, the another player joins', function (t) { + var cancelled = false, + player1 = fix.createNewUser(), + player2 = fix.createNewUser(); + player1.xhr(base + '/status', function (err, res, body) { + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + + var pin = body.data.pin; + + // !! NOTE: use of series here. These fire in order. + async.series({ + one: function (done) { + + /** + * This simulates player who clicked 'New Game' and then hits the + * homepage again, detroying their session and leaving the game + */ + player1.xhr(base + '/', function (err) { + if (err || !body) { return done('Failed to leave'); } + done(); + }); + + }, + two: function (done) { + + /** + * This simulates player who clicked 'Join Game' and entered `pin` + * after previous player had left. + */ + var hangingRequest, cancelled; + var opts = { + method: 'POST', + body: { pin: pin } + }; + player2.xhr(base + '/join', opts, + function (err, res, body) { + if (err || !body) { return done('Failed to join'); } + // Hanging with pin + hangingRequest = player2 + .xhr(base + '/status/' + pin, function (err, res, body) { + // If the connection is manually killed, don't error. + if (cancelled) return; + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + t.fail('Status with pin returned'); + t.end(); + }); + + setTimeout(function () { + t.pass('Timed out.'); + cancelled = true; + hangingRequest.req.end(); + hangingRequest.req.destroy(); + done(); + }, 1000); + + }); + } + }, function (err, players) { + if (err) return t.fail(err); + t.end(); + }); + + }); + }); + + t.test('player joins then leaves, then joins with pin', function (t) { + var cancelled = false, + player1 = fix.createNewUser(), + player2 = fix.createNewUser(); + player1.xhr(base + '/status', function (err, res, body) { + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + + var pin = body.data.pin; + + // !! NOTE: use of series here. These fire in order. + async.series({ + one: function (done) { + + /** + * This simulates player who clicked 'New Game' and then hits the + * homepage again, detroying their session and leaving the game + */ + player1.xhr(base + '/', function (err, res, body) { + if (err || !body) { return done('Failed to leave'); } + done(); + }); + + }, + two: function (done) { + + /** + * This simulates player who clicked 'Join Game' and entered `pin` + * after previous player had left. + */ + var hangingRequest, cancelled; + var opts = { + method: 'POST', + body: { pin: pin } + }; + player2.xhr(base + '/join', opts, + function (err, res, body) { + if (err || !body) { return done('Failed to join'); } + // Hanging with pin + hangingRequest = player2 + .xhr(base + '/status/' + pin, function (err, res, body) { + // If the connection is manually killed, don't error. + if (cancelled) return; + if (err || !res) { return t.fail('Error. ' + err) && t.end(); } + t.fail('Status with pin returned'); + t.end(); + }); + + setTimeout(function () { + t.pass('Timed out.'); + cancelled = true; + hangingRequest.req.end(); + hangingRequest.req.destroy(); + done(); + }, 1000); + + }); + + } + }, function (err, players) { + if (err) return t.fail(err); + t.end(); + }); + + }); + }); + +}); \ No newline at end of file