diff --git a/.editorconfig b/.editorconfig index 9e32b41..291897e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,4 +9,8 @@ trim_trailing_whitespace = true insert_final_newline = true [*.md] -trim_trailing_whitespace = false \ No newline at end of file +trim_trailing_whitespace = false + +[*.yaml] +indent_style = spaces +indent_size = 2 diff --git a/app/api/game.yaml b/app/api/game.yaml new file mode 100644 index 0000000..bb100b1 --- /dev/null +++ b/app/api/game.yaml @@ -0,0 +1,163 @@ +swagger: '2.0' +info: + version: 0.1.0 + title: 'Torwolf Games API' +paths: + /games: + get: + description: Finds games + parameters: + - name: offset + in: query + description: The offset at which the result set should begin. + required: false + type: integer + default: 0 + - name: limit + in: query + description: Result will contain at most limit items + required: false + type: integer + default: 20 + - name: phase + in: query + description: Games in the result set should be in the provided list of comma separated phases. + required: false + type: string + enum: ["FORMING", "STARTED", "COMPLETED"] + - name: name + in: query + description: Games should match the provided text search string + required: false + type: string + responses: + 200: + description: Successful response + schema: + $ref: '#/definitions/ResultSet' + # TODO: error middleware + default: + description: Unexpected error + schema: + $ref: '#/definitions/Error' + post: + description: Creates a game + parameters: + - name: game + in: body + description: Game to create + required: true + schema: + $ref: '#/definitions/Game' + responses: + 200: + description: Successful response + schema: + $ref: '#/definitions/Game' + default: + description: Unexpected error + schema: + $ref: '#/definitions/Error' + /games/{id}: + get: + description: Gets a game + parameters: + - name: id + in: path + description: Id of the game to get + required: true + type: number + format: i32 + responses: + 200: + description: Successful response + schema: + $ref: '#/definitions/Game' + # TODO: error middleware + 404: + description: Game was not found + schema: + $ref: '#/definitions/Error' + default: + description: Unexpected error + schema: + $ref: '#/definitions/Error' + +definitions: + Game: + required: + - name + - description + properties: + name: + type: string + description: The name of this game. + description: + type: string + description: A description of what to expect in this game. + victor: + type: string + description: Which side won the game. + enum: ['GOVERNMENT', 'REBELLION'] + phase: + type: string + description: Which phase the game is currently in. + enum: ['FORMING', 'STARTED', 'COMPLETED'] + startedAt: + type: string + format: date-time + description: When this game entered the 'STARTED' phase. + completedAt: + type: string + format: date-time + description: When this game entered the 'COMPLETED' phase. + createdAt: + type: string + format: date-time + description: When this game was created. + updatedAt: + type: string + format: date-time + description: When this game was last updated. + id: + type: integer + description: A unique identifier for this game. + example: + name: Example Game + description: A really good example + victor: REBELLION + phase: COMPLETED + startedAt: 2015-10-04 05:05:09.877 +00:00 + completedAt: 2015-10-04 05:05:09.877 +00:00 + createdAt: 2015-10-04 05:05:09.877 +00:00 + updatedAt: 2015-10-04 05:05:09.877 +00:00 + id: 39 + + ResultSet: + type: object + required: + - start + - totalCount + - results + properties: + start: + type: integer + format: int32 + totalCount: + type: integer + format: i32 + results: + type: array + items: + $ref: '#/definitions/Game' + + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/app/classes.js b/app/classes.js new file mode 100644 index 0000000..1248238 --- /dev/null +++ b/app/classes.js @@ -0,0 +1,39 @@ +var uuid = require('uuid'), + constants = require('../constants'), + words = require('../words'); + +exports.Interaction = function() { + this.id = uuid.v4(); + this.isSsl = false; + this.isTor = false; + this.message = null; // The message that initiated this interaction + this.responses = []; // The messages that have been sent as a response to this interaction + this.socket = null; // The socket that initiated this interaction +}; + +exports.Rumor = function(gameId) { + this.generateText = function() { + var rumorText = ""; + for(var x = 0 ; x < 2 ; ++x) { + var word = words.words[Math.floor(Math.random() * words.words.length)]; + rumorText += ((x===0)?"":" ") + word; + } + return rumorText; + }; + + this.gameId = gameId; + this.id = uuid.v4(); + this.truthStatus = ""; + this.publicationStatus = ""; + this.sourceId = ""; + this.text = this.generateText(); + this.transfers = []; + + this.getPlayerTruthStatus = function(player) { + if (this.publicationStatus == constants.RUMOR_PUBLICATIONSTATUS_PUBLISHED || this.sourceId === player.id) { + return this.truthStatus; + } else { + return constants.RUMOR_TRUTHSTATUS_UNKNOWN; + } + }; +}; diff --git a/app/controllers/game.server.controller.js b/app/controllers/game.server.controller.js new file mode 100644 index 0000000..2d61cb9 --- /dev/null +++ b/app/controllers/game.server.controller.js @@ -0,0 +1,65 @@ +var gameRepository = require('../repositories/game'); +var async = require('async'); + +/** + * Module dependencies. + */ + +exports.create = function(req, res, next) { + 'use strict'; + gameRepository.create(req.body, function(err, game) { + if (err) { + return next(err); + } + return res.json(game); + }); +}; + +exports.find = function(req, res, next) { + 'use strict'; + var options = { + offset: req.query.offset || 0, + limit: req.query.limit || 20, + where: { } + }; + if (req.query.name) { + var queryString = '%' + req.query.name + '%'; + options.where.name = { + $like: queryString + }; + } + if (req.query.phase) { + var phases = req.query.phase.split(','); + options.where.phase = { + $in: phases + }; + } + + async.parallel([ + function(cb) { + gameRepository.find(options, cb); + }, + function(cb) { + gameRepository.count(options, cb); + } + ], function(err, results) { + if (err) { + return next(err); + } + return res.json({ + start: parseInt(options.offset), + totalCount: results[1], + results: results[0] + }); + }); +}; + +exports.get = function(req, res, next) { + 'use strict'; + gameRepository.get(req.params.id, function(err, game) { + if (err) { + return next(err); + } + return res.json(game); + }); +}; diff --git a/app/handlers/error.js b/app/handlers/error.js new file mode 100644 index 0000000..0279e72 --- /dev/null +++ b/app/handlers/error.js @@ -0,0 +1,10 @@ +var payloads = require('../../payloads'); +var messageTypes = require('../../message-types'); + +// Functions +module.exports = function (message, socket) { + var error = new payloads.ErrorPayload(message); + socket.emit('error', { + payload: error.getPayload() + }); +}; diff --git a/app/handlers/heartbeat.js b/app/handlers/heartbeat.js new file mode 100644 index 0000000..54b8d06 --- /dev/null +++ b/app/handlers/heartbeat.js @@ -0,0 +1,33 @@ +var constants = require('../../constants'), + payloads = require('../../payloads'), + locales = require('../../locales'), + error = require('./error'), + messageSender = require('./messageSender'), + gameRepository = require('../repositories/game'), + messageTypes = require('../../message-types'); + +exports.handle = function(payload, interaction) { + var socket = interaction.socket; + var count = payload.data.count; + var game = gameRepository.get(payload.data.gameId, function (err, game) { + if (err) { + throw err; + } + + // Did the game end? + if (game.phase === 'COMPLETED') { + return; + } + + // Start the next heartbeat + return setTimeout(function() { + var heartbeatPayload = new payloads.StorytellerHeartbeatOutPayload(++count); + messageSender.send( + heartbeatPayload.getPayload(), + socket, + interaction); + }, constants.TICK_HEARTBEAT); + }); + +}; + diff --git a/app/handlers/messageSender.js b/app/handlers/messageSender.js new file mode 100644 index 0000000..177b0ae --- /dev/null +++ b/app/handlers/messageSender.js @@ -0,0 +1,39 @@ +var config = require('../../config'); +var serverSocket = require('socket.io-client')(config.socketIoHost + ':' + config.socketIoPort); + +exports.sendToServer = function(payload) { + var message = { + payload: payload + }; + + serverSocket.emit('message', message); +}; + +exports.send = function(payload, sockets, interaction) { + if (!(sockets instanceof Array)) { + sockets = [sockets]; + } + + var message = { + payload: payload + }; + + // Add to the interaction + if(interaction) { + interaction.responses.push(message); + message.interactionId = interaction.id; + } + + // TODO: Snoop the interaction + if(interaction) { + // var interceptIn = new payloads.SnooperInterceptInPayload(interaction); + // exports.routeMessage( + // constants.COMMUNICATION_TARGET_SNOOPER, + // interceptIn.getPayload(), + // constants.COMMUNICATION_SOCKET_SERVER); + } + + for (var socket in sockets) { + sockets[socket].emit('message', message); + } +}; diff --git a/app/handlers/newspaper/publish.js b/app/handlers/newspaper/publish.js new file mode 100644 index 0000000..518d0ec --- /dev/null +++ b/app/handlers/newspaper/publish.js @@ -0,0 +1,5 @@ +// TODO: actually do something + +exports.handle = function (data, interaction) { + return; +}; diff --git a/app/handlers/router.js b/app/handlers/router.js new file mode 100644 index 0000000..bf72061 --- /dev/null +++ b/app/handlers/router.js @@ -0,0 +1,30 @@ +var classes = require('../classes'), + constants = require('../../constants'), + payloads = require('../../payloads'), + gameState = require('../lib/gameState'), + routingTable = require('./routingTable'), + logger = require('../lib/logger').logger; + +exports.receiveMessage = function(message, socket) { + if(!message.payload || !message.payload.type) { + logger.info("Invalid payload received " + JSON.stringify(message)); + return; // Invalid payload + } + + // Set up metadata about the message + if(gameState.getInteractionById(message.interactionId)) { + logger.info("Duplicate interaction received " + message.interactionId); + return; // Duplicate interaction + } + // Build the interaction + var interaction = new classes.Interaction(); + interaction.id = message.interactionId?message.interactionId:interaction.id; + interaction.message = message; + interaction.isTor = message.isTor?true:false; + interaction.isSsl = message.isSsl?true:false; + interaction.socket = socket; + gameState.storeInteraction(interaction); + message.interactionId = interaction.id; + + routingTable[message.payload.type].handle(message.payload, interaction); +}; diff --git a/app/handlers/routingTable.js b/app/handlers/routingTable.js new file mode 100644 index 0000000..863b190 --- /dev/null +++ b/app/handlers/routingTable.js @@ -0,0 +1,24 @@ +var messageTypes = require('../../message-types'), + heartbeat = require('./heartbeat'), + start = require('./storyteller/startGame'), + join = require('./storyteller/joinGame'), + tick = require('./storyteller/tick'), + ircSubpoena = require('./storyteller/subpoenaIrc'), + emailSubpoena = require('./storyteller/subpoenaEmail'), + kill = require('./storyteller/kill'), + end = require('./storyteller/end'), + publish = require('./newspaper/publish'), + table = {}; + +table[messageTypes.STORYTELLER_HEARTBEATPONG] = heartbeat; +table[messageTypes.STORYTELLER_JOIN] = join; +table[messageTypes.STORYTELLER_START] = start; +table[messageTypes.STORYTELLER_TICK] = tick; +table[messageTypes.STORYTELLER_IRCSUBPOENA] = ircSubpoena; +table[messageTypes.STORYTELLER_EMAILSUBPOENA] = emailSubpoena; +table[messageTypes.STORYTELLER_KILL] = kill; +table[messageTypes.STORYTELLER_END] = end; + +table[messageTypes.NEWSPAPER_PUBLISH] = publish; + +module.exports = table; diff --git a/app/handlers/storyteller/end.js b/app/handlers/storyteller/end.js new file mode 100644 index 0000000..aae15e7 --- /dev/null +++ b/app/handlers/storyteller/end.js @@ -0,0 +1,22 @@ +var gameState = require('../../lib/gameState'), + payloads = require('../../../payloads'), + locales = require('../../../locales'), + messageSender = require('../messageSender'), + gameRepository = require('../../repositories/game'), + logger = require('../../lib/logger').logger; + +exports.handle = function (data, interaction) { + var game = gameState.getGameById(data.data.gameId); + game.phase = 'COMPLETED'; + gameRepository.update(game, game.id, function (err, game) { + if (err) { + logger.error(err); + } + }); + + // That's all folks! + var announcementOut = new payloads.StorytellerAnnouncementOutPayload(locales[game.locale].messages.storyteller.GAMEOVER); + messageSender.send( + announcementOut.getPayload(), + gameState.getSocketsByGameId(game.id)); +}; diff --git a/app/handlers/storyteller/joinGame.js b/app/handlers/storyteller/joinGame.js new file mode 100644 index 0000000..b68ed46 --- /dev/null +++ b/app/handlers/storyteller/joinGame.js @@ -0,0 +1,69 @@ +var constants = require('../../../constants'), + payloads = require('../../../payloads'), + locales = require('../../../locales'), + error = require('../error'), + messageSender = require('../messageSender'), + gameRepository = require('../../repositories/game'), + gameState = require('../../lib/gameState'), + userRepository = require('../../repositories/user'), + async = require('async'), + logger = require('../../lib/logger').logger; + +exports.handle = function(payload, interaction) { + var socket = interaction.socket; + async.parallel([ + function(cb) { + gameRepository.get(payload.data.gameId, cb); + }, + function(cb) { + userRepository.get(payload.data.playerId, cb); + } + ], function(error, results) { + if (error) { + throw error; + } + var game = results[0]; + var player = results[1]; + + if (!gameState.getGameById(game.id)) { + gameState.storeGame(game); + } + + // Tell the player who is in the game + var joinOut = null; + for(var x in gameState.getGameById(game.id).players) { + // TODO: replace with state replay + var otherPlayer = gameState.getGameById(game.id).players[x]; + joinOut = new payloads.StorytellerJoinOutPayload(otherPlayer); + messageSender.send( + joinOut.getPayload(), + socket, + interaction); + } + + // Announce the entrance of the player + joinOut = new payloads.StorytellerJoinOutPayload(player); + + messageSender.send( + joinOut.getPayload(), + gameState.getSocketsByGame(game) + ); + + gameState.storeSocket(socket, player.id); + gameState.addPlayerToGame(game.id, player, socket); + + // FIXME: do not hard code length + if(gameState.getGameById(game.id).players.length == 8) { + var startOut = new payloads.StorytellerStartInPayload(game); + messageSender.sendToServer( + startOut.getPayload()); + } + + // TODO: Have them join IRC + // var joinIn = new payloads.IrcJoinInPayload(player.name); + // communication.routeMessage( + // constants.COMMUNICATION_TARGET_IRC, + // joinIn.getPayload(), + // socket); + }); +}; diff --git a/app/handlers/storyteller/kill.js b/app/handlers/storyteller/kill.js new file mode 100644 index 0000000..a470c68 --- /dev/null +++ b/app/handlers/storyteller/kill.js @@ -0,0 +1,56 @@ +var gameState = require('../../lib/gameState'), + error = require('../error'), + locales = require('../../../locales'), + messageSender = require('../messageSender'), + constants = require('../../../constants'), + payloads = require('../../../payloads'); + +exports.handle = function (data, interaction) { + var socket = interaction.socket; + var playerRole = gameState.getRoleByPlayerId(data.data.playerId); + var killer = gameState.getPlayerBySocketId(socket.id); + var killerRole = gameState.getRoleByPlayerId(data.data.playerId); + var game = gameState.getGameById(data.data.gameId); + + if(!playerRole) { + return error(locales[socket.locale].errors.storyteller.KILL_NOBODY, socket); + } + + if(killerRole != constants.PLAYER_ROLE_AGENT) { + return error(locales[socket.locale].errors.storyteller.KILL_ILLEGAL, socket); + } + + var killOut = new payloads.StorytellerKillOutPayload({ id : data.data.playerId }); + messageSender.send( + killOut.getPayload(), + gameState.getSocketsByGameId(game.id)); + + // Check victory conditions + var endIn = null; + var announcementOut = null; + if(playerRole == constants.PLAYER_ROLE_ACTIVIST) { + announcementOut = new payloads.StorytellerAnnouncementOutPayload(locales[game.locale].messages.storyteller.VICTORY_GOVERNMENT_ACTIVIST); + messageSender.send( + announcementOut.getPayload(), + gameState.getSocketsByGameId(game.id)); + + endIn = new payloads.StorytellerEndInPayload(game); + messageSender.sendToServer(endIn.getPayload()); + } else if(playerRole == constants.PLAYER_ROLE_JOURNALIST) { + announcementOut = new payloads.StorytellerAnnouncementOutPayload(locales[game.locale].messages.storyteller.VICTORY_GOVERNMENT_JOURNALIST); + messageSender.send( + announcementOut.getPayload(), + gameState.getSocketsByGameId(game.id)); + + endIn = new payloads.StorytellerEndInPayload(game); + messageSender.routeMessage(endIn.getPayload()); + } else { + announcementOut = new payloads.StorytellerAnnouncementOutPayload(locales[game.locale].messages.storyteller.VICTORY_ACTIVISTS_KILLING); + messageSender.send( + announcementOut.getPayload(), + gameState.getSocketsByGameId(game.id)); + + endIn = new payloads.StorytellerEndInPayload(game); + messageSender.sendToServer(endIn.getPayload()); + } +}; diff --git a/app/handlers/storyteller/leaveGame.js b/app/handlers/storyteller/leaveGame.js new file mode 100644 index 0000000..2b40b49 --- /dev/null +++ b/app/handlers/storyteller/leaveGame.js @@ -0,0 +1,21 @@ +exports.handle = function(payload, interaction) { + // TODO: implement ability to leave game + + // Do the Mario! + + // Swing your arms from side to side + // Come on, it's time to go! + // Do the Mario! + // Take one step, and then again. + // Let's do the Mario, all together now! + // You've got it! + // It's the Mario! + // Do the Mario! + // Swing your arms from side to side + // Come on, it's time to go! + // Do the Mario! + // Take one step, and then again. + // Let's do the Mario, all together now! + + // Come on now, just like that! +}; diff --git a/app/handlers/storyteller/startGame.js b/app/handlers/storyteller/startGame.js new file mode 100644 index 0000000..f5f3b14 --- /dev/null +++ b/app/handlers/storyteller/startGame.js @@ -0,0 +1,110 @@ +var async = require('async'), + gameRepository = require('../../repositories/game'), + gameState = require('../../lib/gameState'), + messageSender = require('../messageSender'), + payloads = require('../../../payloads'), + classes = require('../../classes'), + constants = require('../../../constants'), + logger = require('../../lib/logger').logger, + _ = require('lodash'); + +exports.handle = function(payload, interaction) { + var socket = interaction.socket; + async.waterfall([ + function(callback) { + gameRepository.get(payload.data.gameId, callback); + }, + function(game, callback) { + // Assign information to the players + var roles = [ + constants.PLAYER_ROLE_ACTIVIST, + constants.PLAYER_ROLE_CITIZEN_ACTIVIST, + constants.PLAYER_ROLE_CITIZEN_ACTIVIST, + constants.PLAYER_ROLE_CITIZEN_ACTIVIST, + constants.PLAYER_ROLE_CITIZEN_APATHETIC, + constants.PLAYER_ROLE_JOURNALIST, + constants.PLAYER_ROLE_AGENT, + constants.PLAYER_ROLE_CITIZEN_AGENT + ]; + + var gameStateGame = gameState.getGameById(game.id); + for(var x in gameStateGame.players) { + var player = gameStateGame.players[x]; + + // Assign a "random" role + var role = roles.pop(); + player.role = role; + gameState.assignRole(game.id, player.id, role); + + // Tell the player his role + var roleOut = new payloads.StorytellerRoleOutPayload(player); + messageSender.send( + roleOut.getPayload(), + gameState.getSocketByPlayerId(player.id)); + + switch(player.role) { + case constants.PLAYER_ROLE_ACTIVIST: + case constants.PLAYER_ROLE_CITIZEN_ACTIVIST: + player.allegiance = constants.PLAYER_ALLEGIANCE_REBELLION; + break; + case constants.PLAYER_ROLE_CITIZEN_APATHETIC: + case constants.PLAYER_ROLE_JOURNALIST: + player.allegiance = constants.PLAYER_ALLEGIANCE_NEUTRAL; + break; + case constants.PLAYER_ROLE_AGENT: + case constants.PLAYER_ROLE_CITIZEN_AGENT: + player.allegiance = constants.PLAYER_ALLEGIANCE_GOVERNMENT; + break; + } + + var allegianceOut = new payloads.StorytellerAllegianceOutPayload(player); + messageSender.send( + allegianceOut.getPayload(), + gameState.getSocketByPlayerId(player.id)); + + // Give the player his starting rumors + var rumor = new classes.Rumor(game.id); + rumor.destinationId = player.id; + rumor.publicationStatus = constants.RUMOR_PUBLICATIONSTATUS_UNPUBLISHED; + rumor.sourceId = constants.RUMOR_SOURCE_SYSTEM; + rumor.truthStatus = undefined; + if (player.role == constants.PLAYER_ROLE_ACTIVIST) { + rumor.truthStatus = constants.RUMOR_TRUTHSTATUS_TRUE; + } else { + rumor.truthStatus = constants.RUMOR_TRUTHSTATUS_FALSE; + } + gameState.storeRumor(rumor); + + var rumorOut = new payloads.StorytellerRumorOutPayload(rumor); + rumorOut.destinationId = player.id; + rumorOut.sourceId = player.id; + rumorOut.truthStatus = rumor.truthStatus; + + messageSender.send( + rumorOut.getPayload(), + gameState.getSocketByPlayerId(player.id)); + + // TODO: agent + // + // if(player.role == constants.PLAYER_ROLE_SPY) { + // snooper.sendPayload( + // activateOut.getPayload(), + // communication.getSocketByPlayerId(player.id)); + // } + } + + // Start first turn + var tickIn = new payloads.StorytellerTickInPayload(game, 1); + + messageSender.sendToServer( + tickIn.getPayload()); + + game.phase = 'STARTED'; + gameRepository.update(game, game.id, callback); + } + ], function(err) { + if (err) { + logger.error(err); + } + }); +}; diff --git a/app/handlers/storyteller/subpoenaEmail.js b/app/handlers/storyteller/subpoenaEmail.js new file mode 100644 index 0000000..9d99742 --- /dev/null +++ b/app/handlers/storyteller/subpoenaEmail.js @@ -0,0 +1,10 @@ +var messageSender = require('../messageSender'), + gameState = require('../../lib/gameState'), + payloads = require('../../../payloads'); + +exports.handle = function(payload, interaction) { + var outPayload = new payloads.StorytellerSubpoenaEmailOutPayload(); + messageSender.send( + outPayload.getPayload(), + gameState.getSocketsByGameId(payload.data.gameId)); +}; diff --git a/app/handlers/storyteller/subpoenaIrc.js b/app/handlers/storyteller/subpoenaIrc.js new file mode 100644 index 0000000..1ba5932 --- /dev/null +++ b/app/handlers/storyteller/subpoenaIrc.js @@ -0,0 +1,10 @@ +var messageSender = require('../messageSender'), + gameState = require('../../lib/gameState'), + payloads = require('../../../payloads'); + +exports.handle = function(payload, interaction) { + var outPayload = new payloads.StorytellerSubpoenaIrcOutPayload(); + messageSender.send( + outPayload.getPayload(), + gameState.getSocketsByGameId(payload.data.gameId)); +}; diff --git a/app/handlers/storyteller/tick.js b/app/handlers/storyteller/tick.js new file mode 100644 index 0000000..4780f26 --- /dev/null +++ b/app/handlers/storyteller/tick.js @@ -0,0 +1,112 @@ +var constants = require('../../../constants'), + messageSender = require('../messageSender'), + payloads = require('../../../payloads'), + gameState = require('../../lib/gameState'), + locales = require('../../../locales'), + util = require('util'); + +exports.handle = function(payload, interaction) { + var round = payload.data.round; + var game = gameState.getGameById(payload.data.gameId); + + // notify clients of next tick + var thisTick = new payloads.StorytellerTickOutPayload(); + messageSender.send( + thisTick.getPayload(), + gameState.getSocketsByGame(game) + ); + + // Announce the end of the turn + var announcementOut = new payloads.StorytellerAnnouncementOutPayload( + util.format(locales[game.locale].messages.storyteller.ROUND_END, + game.round + ) + ); + + messageSender.send( + announcementOut.getPayload(), + gameState.getSocketsByGameId(game.id)); + + // Publish the newspaper + var publishAnnouncement = null; + if(Object.keys(game.activeInvestigations).length === 0) { + publishAnnouncement = new payloads.StorytellerAnnouncementOutPayload( + locales[game.locale].messages.storyteller.NEWS_NOTHING + ); + } else { + publishAnnouncement = new payloads.StorytellerAnnouncementOutPayload( + locales[game.locale].messages.storyteller.NEWS_SOMETHING + ); + } + messageSender.send( + announcementOut.getPayload(), + gameState.getSocketsByGameId(game.id)); + + var publishIn = new payloads.NewspaperPublishInPayload(game); + messageSender.sendToServer(publishIn.getPayload()); + + // Reveal the rumors' truth + for(var investigationIndex in game.activeInvestigations) { + var rumor = game.activeInvestigations[investigationIndex]; + rumor.publicationStatus = constants.RUMOR_PUBLICATIONSTATUS_PUBLISHED; + var rumorOut = new payloads.StorytellerRumorOutPayload(rumor); + rumorOut.sourceId = constants.RUMOR_SOURCE_NEWSPAPER; + rumorOut.truthStatus = rumor.truthStatus; + messageSender.send( + rumorOut.getPayload(), + gameState.getSocketsByGameId(game.id)); + } + + // Clear out the rumor mill + gameState.processInvestigations(game); + + // Check for activist victory + var verifiedRumorCount = 0; + for(var rumorIndex in game.rumors) { + if(game.rumors[rumorIndex].publicationStatus == constants.RUMOR_PUBLICATIONSTATUS_PUBLISHED && + game.rumors[rumorIndex].truthStatus == constants.RUMOR_TRUTHSTATUS_TRUE) + verifiedRumorCount += 1; + } + + if(verifiedRumorCount >= game.rumorCount) { + var activistsWonAnnouncement = new payloads.StorytellerAnnouncementOutPayload(locales[game.locale].messages.storyteller.VICTORY_ACTIVISTS_MEDIA); + messageSender.send( + activistsWonAnnouncement.getPayload(), + gameState.getSocketsByGameId(game.id)); + + var endIn = new payloads.StorytellerEndInPayload(game); + messageSender.sendToServer(endIn.getPayload()); + return; + } + + // Announce the beginning of the turn + var startTurnAnnouncement = new payloads.StorytellerAnnouncementOutPayload( + util.format(locales[game.locale].messages.storyteller.ROUND_BEGIN, + game.round + ) + ); + messageSender.send( + startTurnAnnouncement.getPayload(), + gameState.getSocketsByGameId(game.id)); + + if (round === constants.TICK_IRCSUBPOENA) { + var ircPayload = new payloads.StorytellerSubpoenaIrcInPayload(game); + messageSender.sendToServer( + ircPayload.getPayload() + ); + } + + if (round === constants.TICK_EMAILSUBPOENA) { + var emailPayload = new payloads.StorytellerSubpoenaEmailInPayload(game); + messageSender.sendToServer( + emailPayload.getPayload() + ); + } + + // prepare for next tick + setTimeout(function() { + var nextTick = new payloads.StorytellerTickInPayload(game, ++round); + messageSender.sendToServer( + nextTick.getPayload()); + }, constants.TICK_LENGTH); +}; diff --git a/app/lib/database.js b/app/lib/database.js index 56d6026..555362d 100644 --- a/app/lib/database.js +++ b/app/lib/database.js @@ -13,7 +13,7 @@ if(!global.hasOwnProperty('database')) { host: config.sqlHost, port: config.sqlPort, logging: function (message) { - logger.debug(message); + // logger.debug(message); }, dialect: 'postgres', // underscore casing diff --git a/app/lib/gameState.js b/app/lib/gameState.js new file mode 100644 index 0000000..cf1252b --- /dev/null +++ b/app/lib/gameState.js @@ -0,0 +1,93 @@ +var classes = require('../classes'), + interactions = {}, + sockets = {}, + investigations = {}, + rumors = {}, + games = {}, + roles = {}, + players = {}, + playersBySocketId = {}; + +exports.processInvestigations = function (game) { + for(var x in game.activeInvestigations) { + var investigation = game.activeInvestigations[x]; + delete this.activeInvestigations[rumorId]; + game.pastInvestigations[rumorId] = rumor; + } +}; + +exports.assignRole = function(gameId, playerId, role) { + roles[playerId] = role; + var game = games[gameId]; + if (!game.roles[role]) { + game.roles[role] = []; + } + game.roles[role].push(playerId); +}; + +exports.getRoleByPlayerId = function(playerId) { + return roles[playerId]; +}; + +exports.addPlayerToGame = function(gameId, player, socket) { + games[gameId].players.push(player); + players[player.id] = player; + playersBySocketId[socket.id] = player; +}; + +exports.storeGame = function(game) { + game.roles = {}; + game.players = []; + games[game.id] = game; + game.activeInvestigations = {}; + game.pastInvestigations = {}; + game.rumorCount = 3; + // TODO: locales + game.locale = 'default'; +}; + +exports.getGameById = function(id) { + return games[id]; +}; + +exports.storeRumor = function(rumor) { + rumors[rumor.id] = rumor; +}; + +exports.getRumorById = function(id) { + return rumors[id]; +}; + +exports.getInteractionById = function(interactionId) { + return interactions[interactionId]; +}; + +exports.storeInteraction = function(interaction) { + interactions[interaction.id] = interaction; +}; + +exports.storeSocket = function(socket, playerId) { + sockets[playerId] = socket; +}; + +exports.getPlayerBySocketId = function(socketId) { + return playersBySocketId[socketId]; +}; + +exports.getSocketByPlayerId = function(playerId) { + return sockets[playerId]; +}; + +exports.getSocketsByGameId = function(gameId) { + var game = exports.getGameById(gameId); + return exports.getSocketsByGame(game); +}; + +exports.getSocketsByGame = function(game) { + var sockets = []; + game = exports.getGameById(game.id); + for(var x in game.players) { + sockets.push(exports.getSocketByPlayerId(game.players[x].id)); + } + return sockets; +}; diff --git a/app/models/game.js b/app/models/game.js index ea47a6b..cf62f82 100644 --- a/app/models/game.js +++ b/app/models/game.js @@ -4,8 +4,7 @@ var Sequelize = require('sequelize'); var schema = { name: { type: Sequelize.STRING, - allowNull: false, - unique: true + allowNull: false }, description: { type: Sequelize.TEXT, @@ -21,27 +20,45 @@ var schema = { }, phase: { type: Sequelize.ENUM('FORMING', 'STARTED', 'COMPLETED'), + defaultValue: 'FORMING', allowNull: false }, victor: { type: Sequelize.ENUM('REBELLION', 'GOVERNMENT') + }, + createdAt: { + type: Sequelize.DATE, + field: 'created_at' + }, + updatedAt: { + type: Sequelize.DATE, + field: 'updated_at' } }; var options = { + createdAt: 'createdAt', + updatedAt: 'updatedAt', underscored: true, + timestamps: true, tableName: 'game', indexes: [{ - name: 'phase_index', - method: 'BTREE', - fields: ['phase'] - }] + name: 'phase_index', + method: 'BTREE', + fields: ['phase'] + }, { + name: 'name_index', + method: 'BTREE', + fields: ['name'] + }] }; module.exports = function(sequelize, DataTypes) { var User = sequelize.import(__dirname + '/user'); var Game = sequelize.define('Game', schema, options); - Game.hasMany(User); + Game.belongsToMany(User, { + through: 'user_game' + }); return Game; }; -//#JSCOVERAGE_ENDIF \ No newline at end of file +//#JSCOVERAGE_ENDIF diff --git a/app/models/user.js b/app/models/user.js index 406f998..d6b605d 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -44,7 +44,7 @@ var schema = { createdAt: { type: Sequelize.DATE, field: 'created_at' - }, + }, updatedAt: { type: Sequelize.DATE, field: 'updated_at' @@ -63,6 +63,6 @@ var options = { }; module.exports = function (sequelize, DataTypes) { - return sequelize.define("User", schema, options); + return sequelize.define("User", schema, options); }; -//#JSCOVERAGE_ENDIF \ No newline at end of file +//#JSCOVERAGE_ENDIF diff --git a/app/repositories/game.js b/app/repositories/game.js new file mode 100644 index 0000000..c15a3d7 --- /dev/null +++ b/app/repositories/game.js @@ -0,0 +1,56 @@ +var sequelize = global.database.sequelize, + Game = sequelize.import(__dirname + '/../models/game'), + User = sequelize.import(__dirname + '/../models/user'), + _ = require('lodash'); + +module.exports = { + get: function(id, cb) { + Game.findById(id, { include: [User] }) + .then(function(game) { + if (!game) { + return cb(new Error("Could not find game with id " + id)); + } else { + return cb(null, game); + } + }).catch(function(error) { + return cb(error); + }); + }, + + create: function (game, cb) { + Game.create(game) + .then(function(game) { + return cb(null, game); + }).catch(function(error) { + return cb(error); + }); + }, + + find: function (options, cb) { + Game.findAll(options) + .then(function(games) { + return cb(null, games); + }).catch(function(error) { + return cb(error); + }); + }, + + count: function (options, cb) { + Game.count(options) + .then(function(count) { + cb(null, count); + }).catch(function(error) { + return cb(error); + }); + }, + + update: function (game, id, cb) { + game.id = id; + game.save(game) + .then(function(result) { + return cb(null, result); + }).catch(function(error) { + return cb(error); + }); + } +}; diff --git a/app/routes/game.server.routes.js b/app/routes/game.server.routes.js new file mode 100644 index 0000000..bf4f9b0 --- /dev/null +++ b/app/routes/game.server.routes.js @@ -0,0 +1,13 @@ +var controller = require('../controllers/game.server.controller'); +var express = require('express'); +var gameRouter = express.Router(); +var ensureLoggedIn = require('connect-ensure-login').ensureLoggedIn; + +module.exports = function (app) { + 'use strict'; + // user routing + gameRouter.post('/', ensureLoggedIn('/login'), controller.create); + gameRouter.get('/:id', ensureLoggedIn('/login'), controller.get); + gameRouter.get('/', ensureLoggedIn('/login'), controller.find); + app.use('/games', gameRouter); +}; diff --git a/config/env/local.js.example b/config/env/local.js.example index e067ad3..04ae8f5 100644 --- a/config/env/local.js.example +++ b/config/env/local.js.example @@ -4,5 +4,12 @@ module.exports = { app: { title: 'Torwolf', }, - port: 3000 + port: 3000, + sqlDatabase: 'torwolf', + sqlUser: 'user', + sqlPassword: '', + sqlHost: 'host', + sqlPort: 5432, + socketIoHost: 'http://localhost', + socketIoPort: 3000 } diff --git a/constants.js b/constants.js new file mode 100644 index 0000000..5cf0262 --- /dev/null +++ b/constants.js @@ -0,0 +1,44 @@ +if(typeof(window) != "undefined") { + var exports = window; +} else { +} + +exports.COMMUNICATION_SOCKET_SERVER = "server"; // allows the server to communicate with itself + +exports.TICK_WARNING = 20000; // Number of milliseconds warning to give before ending the turn +exports.TICK_HEARTBEAT = 10000; // How many milliseconds between each heartbeat +exports.TICK_LENGTH = 60000; // interval between ticks +exports.TICK_EMAILSUBPOENA = 8; // email is subpoena'd on the eighth tick +exports.TICK_IRCSUBPOENA = 3; // irc is subpoena'd on the third tick + +exports.COMMUNICATION_TARGET_EMAIL = "email"; +exports.COMMUNICATION_TARGET_IRC = "irc"; +exports.COMMUNICATION_TARGET_LOBBY = "lobby"; +exports.COMMUNICATION_TARGET_NEWSPAPER = "newspaper"; +exports.COMMUNICATION_TARGET_SNOOPER = "snooper"; +exports.COMMUNICATION_TARGET_STORYTELLER = "storyteller"; +exports.COMMUNICATION_TARGET_TOR = "tor"; + +exports.PLAYER_ALLEGIANCE_GOVERNMENT = "G"; +exports.PLAYER_ALLEGIANCE_NEUTRAL = "N"; +exports.PLAYER_ALLEGIANCE_REBELLION = "R"; +exports.PLAYER_ALLEGIANCE_UNKNOWN = "U"; + +exports.PLAYER_ROLE_ACTIVIST = "ACTIVIST"; +exports.PLAYER_ROLE_CITIZEN_APATHETIC = "APATHETIC_CIVILIAN"; +exports.PLAYER_ROLE_CITIZEN_ACTIVIST = "REBELLION_CIVILIAN"; +exports.PLAYER_ROLE_CITIZEN_AGENT = "GOVERNMENT_CIVILIAN"; +exports.PLAYER_ROLE_JOURNALIST = "JOURNALIST"; +exports.PLAYER_ROLE_AGENT = "AGENT"; + +exports.RUMOR_INVESTIGATIONSTATUS_INVESTIGATING = "I"; +exports.RUMOR_INVESTIGATIONSTATUS_NONE = "N"; +exports.RUMOR_TRUTHSTATUS_TRUE = "T"; +exports.RUMOR_TRUTHSTATUS_FALSE = "F"; +exports.RUMOR_TRUTHSTATUS_UNKNOWN = "U"; +exports.RUMOR_PUBLICATIONSTATUS_PUBLISHED = "P"; +exports.RUMOR_PUBLICATIONSTATUS_UNPUBLISHED = "U"; +exports.RUMOR_SOURCE_SYSTEM = "S"; +exports.RUMOR_SOURCE_NEWSPAPER = "N"; + +exports.LOCALE_DEFAULT = "default"; diff --git a/locales.js b/locales.js new file mode 100644 index 0000000..add7ef0 --- /dev/null +++ b/locales.js @@ -0,0 +1,14 @@ +if(typeof(window) != "undefined") { + window.LOCALE = (navigator.language)?navigator.language:navigator.userLanguage; + var script = document.createElement( 'script' ); + script.type = 'text/javascript'; + script.src = '/locales/' + window.LOCALE + '.js'; + $("head").append(script); + + if(!(LOCALE in localization)) + localization[LOCALE] = localization[window.LOCALE_DEFAULT]; +} else { + // Todo: include every file in locales/ dynamically + exports.localization = {}; + exports["default"] = require("./locales/default.js").localization["default"]; +} diff --git a/locales/default.js b/locales/default.js new file mode 100644 index 0000000..b1ecc48 --- /dev/null +++ b/locales/default.js @@ -0,0 +1,251 @@ +if(typeof(window) != "undefined") { + var exports = window; +} else { +} + +(function() { + var locale = "default"; + exports.localization = {}; + exports.localization[locale] = {}; + + exports.localization[locale]["errors"] = { + email: { + ADDRESS_EMPTY: "you cannot create an empty email address", + ADDRESS_TAKEN: "an account by that name already exists" + }, + irc: { + NICKEXISTS: "The nick %s is already taken." + }, + lobby: { + CREATE_NAME_BLANK: "Your game name cannot be blank.", + CREATE_PASSWORD_BLANK: "You cannot have a private game without a password.", + JOIN_ALREADY_IN_GAME: "You are already in a game.", + JOIN_GAME_FULL: "This game is full.", + JOIN_INCORRECT_PASSWORD: "You did not enter the correct password.", + JOIN_NONEXISTANT_GAME: "The game you tried to join doesn't exist." + }, + newspaper: { + }, + snooper: { + WIRETAP_COUNT: "You have used up all your wiretaps.", + WIRETAP_ROLE: "You cannot wiretap other players.", + INTERCEPT_SYSTEM: "Only the system can intercept a message." + }, + storyteller: { + GAMEOVER_SYSTEM: "Only the system can end the game.", + HEARTBEAT_SYSTEM: "Only the system can trigger a heartbeat.", + INVESTIGATE_NOGAME: "You aren't connected to a game.", + INVESTIGATE_NOJOURNALIST: "Only the journalist may investigate rumors.", + INVESTIGATE_NOPLAYER: "You aren't a registered player on the server.", + INVESTIGATE_NORUMOR: "That rumor doesn't exist in this game.", + INVESTIGATE_OLDRUMOR: "That rumor has already been investigated.", + KILL_ILLEGAL: "You aren't allowed to kill people.", + KILL_NOBODY: "The person you are trying to kill does not exist.", + RUMOR_INVALID_RUMOR: "The specified rumor does not exist.", + RUMOR_INVALID_SOURCE: "The specified player does not know that rumor.", + RUMOR_SYSTEM: "Only the system can decide when rumors are spread.", + JOIN_LOBBY: "You can only join games through the lobby.", + START_SYSTEM: "Only the system can start a game.", + TICK_SYSTEM: "Only the system can trigger a tick.", + }, + tor: { + DISABLED: "Your proxy has not been properly configured." + } + }; + + exports.localization[locale]["gui"] = { + lobby: { + CREATE: "Create", + JOIN: "Join", + PASSWORD_PROMPT: "This game is private. What's the password?", + + badges: { + PRIVATE: "Private" + }, + create: { + ISPRIVATE: "Private", + NAME: "Name:", + PASSWORD: "Password" + }, + + LOBBY: "Lobby" + }, + email: { + tabs: { + ACCOUNT: 'Accounts', + ADDRESSES: 'Address Book', + COMPOSE: 'Compose', + INBOX: 'Inbox', + SETTINGS: 'Settings' + }, + + ADDRESS: "Address", + ATTACH_RUMOR: "Attach a rumor", + BCC: "Bcc", + CC: "Cc", + CREATE: "Create Account", + EMAIL: "Email", + FROM: "From", + REMOVE: "Remove", + RUMOR: "Rumor", + SEND: "Send", + SUBJECT: "Subject", + TO: "To", + TOR: "Use Tor" + }, + irc: { + IRC: "IRC", + SEND: "Send", + }, + newspaper: { + NEWSPAPER: "The Wolf Gazette" + }, + player: { + allegiance: { + G: "Government", + N: "Neutral", + R: "Rebellion", + U: "Unknown" + }, + allegianceCode: { + G: "G", + N: "N", + R: "R", + U: "U" + }, + role: { + A: "Activist", + C: "Citizen", + CA: "Citizen (Activist)", + CS: "Citizen (Spy)", + E: "Editor", + J: "Journalist", + S: "Spy", + U: "Unknown" + }, + roleCode: { + A: "A", + C: "C", + CA: "CA", + CS: "CS", + E: "E", + J: "J", + S: "S", + U: "U" + }, + status: { + A: "Alive", + D: "Dead" + }, + + KILL: "Kill", + YOU: "You" + }, + rumor: { + investigationStatus: { + C: "Complete", + I: "Investigating...", + N: "Not Investigating" + }, + investigationStatusCode: { + C: "C", + I: "I", + N: "N" + }, + publicationStatus: { + P: "Published", + U: "Unpublished" + }, + publicationStatusCode: { + P: "P", + U: "U" + }, + truthStatus: { + T: "True", + F: "False", + U: "Unknown" + }, + truthStatusCode: { + T: "T", + F: "F", + U: "U" + }, + + INVESTIGATE: "Investigate" + }, + snooper: { + SNOOPER: "Snooper v1.0" + }, + storyteller: { + PLAYERS: "Players", + RUMORS: "Rumors", + STORYTELLER: "Storyteller" + }, + tor: { + ACTIVATE: "Activate Tor", + DEACTIVATE: "Deactivate Tor", + TOR: "Tor" + } + }; + + exports.localization[locale]["messages"] = { + irc: { + CONNECTING: "Connecting to IRC... ", + CONNECTED: "Connected.", + JOINED: "has joined the channel.", + LEFT: "has left the channel.", + MOTD: "MOTD for irc.torwolf.net: Welcome to the torwolf IRC server! " + + "Thank you to Dry_Bones for hosting this server, and now, " + + "a message from Dry_Bones: Greetings, denizens of the torwolf IRC" + + "server. Today, I want to tell you about a special fan of the Mario " + + "Party series. Not many people purchase our games, let alone pre-order " + + "them, but a certain fan, slifty, pre-ordered Mario Party 9 many months " + + "before it came out! That's the kind of fan dedication that motivates " + + "us to make Mario Party game after Mario Party game. Thanks for your " + + "support, slifty! And thank you everyone for using torwolf IRC.", + SWITCHNICK: "%s is now known as %s.", + }, + snooper: { + ANONYMOUS: "Someone", + DEFAULT: "Intercepted a message from %s to module %s ", + EMAIL_REGISTER: "%s just registered the email address %s", + EMAIL_SEND: "%s just sent an email", + IRC_JOIN: "%s has joined the channel in IRC", + IRC_LEAVE: "%s has left the channel in IRC", + IRC_MESSAGE: "%s has said something in IRC", + }, + storyteller: { + GAMEOVER: "The game is over.", + KILL: "%s walks up to %s, takes out his gun, and shoots. It is not a pretty scene", + NEWS_NOTHING: "The journalist did not publish an article last month.", + NEWS_SOMETHING: "The journalist published an article last month! It is sitting on your doorstep.", + ROUND_BEGIN: "Month %d has begun.", + ROUND_END: "Month %d has ended.", + VICTORY_ACTIVISTS_MEDIA: "With so many secrets about the government revealed, the world has started to pay attention. The battle isn't over, but the media effort has come to a close. The rebels and the journalist have won.", + VICTORY_ACTIVISTS_KILLING: "The murder of an innocent civilian has sparked outrage across the world. As the newsroom continues to issue reports of corruption and crimes against humanity, the government finally begins to face serious pressure. The battle isn't over, but the media effort has come to a close. The rebels and the journalist have won.", + VICTORY_GOVERNMENT_JOURNALIST: "The journalist has been killed. The world is outraged, but with no source of information that outrage is short lived. The government is able to continue forward as planned. The rebels and the journalist have lost.", + VICTORY_GOVERNMENT_ACTIVIST: "The activist has been killed. Some of the world notices, but it is generally overlooked. With no leadership the rebels fall apart. The government doesn't skip a beat. The rebels and the journalist have lost.", + WARNING: "This turn ends in %d %s.", + WARNING_UNIT_SINGULAR: "second", + WARNING_UNIT_PLURAL: "seconds", + }, + newspaper: { + FALSE_HEADLINES: [ + "No Merit to \'%s\'", + "\'%s\' Hoax Debunked" + ], + FALSE_COPY: [ + "It turns out that the popular rumor about \'%s\' isn't true.", + ], + NO_HEADLINE: "Nothing was written about your country this month", + NO_COPY: "Apparently nothing interesting happened in your country worth reporting on.", + TRUE_HEADLINES: [ + "BREAKING: Secret \'%s\' mission revealed", + "Verified reports of government-run \'%s\' project", + ], + TRUE_COPY: [ + "There are serious concerns about the welfare of the people due to the \'%s\' project.", + ] + } + }; +})(); diff --git a/message-types.js b/message-types.js index 494b9a3..85c5eda 100644 --- a/message-types.js +++ b/message-types.js @@ -68,6 +68,10 @@ exports.STORYTELLER_START = "start"; // Start the game exports.STORYTELLER_STARTED = "started"; // The game has started exports.STORYTELLER_TICK = "tick"; // Trigger a tick exports.STORYTELLER_TOCK = "tock"; // A tick has occurred +exports.STORYTELLER_IRCSUBPOENA = "subpoena-irc"; // subpoena irc server +exports.STORYTELLER_IRCSUBPOENAD = "subpoenad-irc"; // irc server has been subpoena'd +exports.STORYTELLER_EMAILSUBPOENA = "subpoena-email"; // subpoena email server +exports.STORYTELLER_EMAILSUBPOENAD = "subpoenad-email"; // email server has been subpoena'd exports.TOR_CONNECT = "connect"; // Connect to Tor exports.TOR_CONNECTED = "connected"; // Connection to Tor complete diff --git a/package.json b/package.json index 46006c7..fb06e09 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,9 @@ "passport": "0.3.0", "passport-local": "1.0.0", "async": "1.4.2", - "connect-ensure-login": "0.1.1" + "connect-ensure-login": "0.1.1", + "socket.io": "1.3.7", + "uuid": "2.0.1" }, "devDependencies": { "gulp-mocha": "2.1.3", @@ -46,6 +48,7 @@ "supertest": "1.1.0", "mockery": "1.4.0", "gulp-jshint": "1.11.2", - "gulp-watch": "4.3.5" + "gulp-watch": "4.3.5", + "socket.io-client": "1.3.7" } } diff --git a/payloads.js b/payloads.js index c489ac7..96fbe46 100644 --- a/payloads.js +++ b/payloads.js @@ -420,6 +420,47 @@ exports.SnooperWiretapOutPayload = function(player) { } } +exports.StorytellerSubpoenaIrcInPayload = function(game) { + this.game = game; + this.getPayload = function() { + return { + type: constants.STORYTELLER_IRCSUBPOENA, + data: { + gameId: game.id + } + } + }; +}; + +exports.StorytellerSubpoenaIrcOutPayload = function() { + this.getPayload = function() { + return { + type: constants.STORYTELLER_IRCSUBPOENAD, + data: {} + } + }; +} + +exports.StorytellerSubpoenaEmailInPayload = function(game) { + this.game = game; + this.getPayload = function() { + return { + type: constants.STORYTELLER_EMAILSUBPOENA, + data: { + gameId: game.id + } + } + }; +}; + +exports.StorytellerSubpoenaEmailOutPayload = function() { + this.getPayload = function() { + return { + type: constants.STORYTELLER_EMAILSUBPOENAD, + data: {} + } + }; +} exports.StorytellerAllegianceInPayload = function() { } @@ -478,13 +519,13 @@ exports.StorytellerEndOutPayload = function(game) { } -exports.StorytellerHeartbeatInPayload = function(game) { +exports.StorytellerHeartbeatInPayload = function(game, count) { this.game = game; - this.count = 0; + this.count = count; this.getPayload = function() { return { - type: constants.STORYTELLER_HEARTBEATPING, + type: constants.STORYTELLER_HEARTBEATPONG, data: { gameId: this.game.id, count: this.count @@ -497,7 +538,7 @@ exports.StorytellerHeartbeatOutPayload = function(count) { this.count = count; this.getPayload = function() { return { - type: constants.STORYTELLER_HEARTBEATPONG, + type: constants.STORYTELLER_HEARTBEATPING, data: { count: this.count, } @@ -550,13 +591,15 @@ exports.StorytellerJoinOutPayload = function(player) { } } -exports.StorytellerKillInPayload = function(player) { +exports.StorytellerKillInPayload = function(player, game) { this.player = player; + this.game = game; this.getPayload = function() { return { type: constants.STORYTELLER_KILL, data: { - playerId: this.player.id + playerId: this.player.id, + gameId: this.game.id } } } @@ -646,30 +689,32 @@ exports.StorytellerStartInPayload = function(game) { exports.StorytellerStartOutPayload = function(game) { } -exports.StorytellerTickInPayload = function(game) { +exports.StorytellerTickInPayload = function(game, round) { this.game = game; + this.round = round; this.getPayload = function() { return { type: constants.STORYTELLER_TICK, data: { gameId: this.game.id, + round: this.round } } }; }; -exports.StorytellerTickOutPayload = function(game) { - this.game = game; +exports.StorytellerTickOutPayload = function(round) { + this.round = round; this.getPayload = function() { return { type: constants.STORYTELLER_TOCK, data: { - round: this.game.round + round: this.rounds } } }; -} +}; exports.TorConnectInPayload = function() { this.getPayload = function() { diff --git a/server.js b/server.js index fa32ef4..74c95ee 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,12 @@ var chalk = require('chalk'), logger = require('./app/lib/logger').logger, passport = require('passport'), userRepository = require('./app/repositories/user'), - LocalStrategy = require('passport-local'); + LocalStrategy = require('passport-local'), + constants = require('./constants'), + router = require('./app/handlers/router'), + messageSender = require('./app/handlers/messageSender'), + payloads = require('./payloads'), + messageTypes = require('./message-types'); /** * Main application entry file. @@ -39,11 +44,38 @@ passport.use(new LocalStrategy({ } )); +var server = require('http').createServer(app); +var io = require('socket.io')(server); +io.sockets.on('connection', function(socket) { + logger.debug('Socket connected'); + socket.locale = constants.LOCALE_DEFAULT; + var payload = new payloads.StorytellerHeartbeatOutPayload(0); + + setTimeout(function() { + messageSender.send( + payload.getPayload(), + socket); + }, constants.TICK_HEARTBEAT); + + socket.on('locale', function (locale) { + socket.locale = locale; + }); + + socket.on('message', function (payload) { + try { + logger.debug('Received message ' + JSON.stringify(payload)); + router.receiveMessage(payload, socket); + } catch (error) { + logger.error(error); + } + }); +}); + // Start the app by listening on -var server = app.listen(config.port); +server = server.listen(config.port); // Expose app -exports = module.exports = { +exports = module.exports = { app: app, server: server } @@ -53,4 +85,4 @@ logger.info('--'); logger.info(chalk.green(config.app.title + ' application started')); logger.info(chalk.green('Environment:\t\t\t' + process.env.NODE_ENV)); logger.info(chalk.green('Port:\t\t\t\t' + config.port)); -logger.info('--'); \ No newline at end of file +logger.info('--'); diff --git a/test/factories/game.js b/test/factories/game.js new file mode 100644 index 0000000..fb561b4 --- /dev/null +++ b/test/factories/game.js @@ -0,0 +1,6 @@ +exports.create = function () { + return { + name: 'Bowser\s Kingdom', + description: 'Kingdom of the Koopa' + }; +} diff --git a/test/factories/user.js b/test/factories/user.js new file mode 100644 index 0000000..84c03fb --- /dev/null +++ b/test/factories/user.js @@ -0,0 +1,7 @@ +exports.create = function () { + return { + username: 'dry bones' + Math.random(), + password: 'bowser sucks', + email: 'drybones' + Math.random() + '@koopakingdom.com' + }; +} diff --git a/test/integration/gameTest.js b/test/integration/gameTest.js new file mode 100644 index 0000000..618f1d3 --- /dev/null +++ b/test/integration/gameTest.js @@ -0,0 +1,164 @@ +var should = require('chai').should(), + expect = require('chai').expect, + request = require('supertest'), + moment = require('moment'), + async = require('async'), + _ = require('lodash'), + url = 'http://localhost:3000'; + +if (!global.hasOwnProperty('testApp')) { + global.testApp = require('../../server'); +} +var app = global.testApp; + +describe('Game routes', function() { + var game = undefined; + var gameTemplate = undefined; + var agent = request.agent(app.app); + + var assertGame = function(newGame, expectedGame) { + newGame.name.should.equal(expectedGame.name); + newGame.description.should.equal(expectedGame.description); + newGame.id.should.exist; + should.not.exist(newGame.victor); + newGame.phase.should.equal('FORMING'); + should.not.exist(newGame.completedAt); + should.not.exist(newGame.startedAt); + newGame.createdAt.should.exist; + moment(newGame.createdAt).format.should.not.equal('Invalid date'); + newGame.updatedAt.should.exist; + moment(newGame.updatedAt).format.should.not.equal('Invalid date'); + } + + var assertGameById = function(id, expectedGame, agent, cb) { + agent + .get('/games/' + id ) + .end(function(err, response) { + if (err) { + return cb(err); + } + response.statusCode.should.equal(200); + var game = response.body; + assertGame(game, expectedGame); + cb() + }); + } + + beforeEach(function(done) { + gameTemplate = { + name: 'Bowser\s Kingdom', + description: 'Kingdom of the Koopa' + }; + var user = { + username: 'dry bones' + Math.random(), + password: 'bowser sucks', + email: 'drybones' + Math.random() + '@koopakingdom.com' + }; + + async.waterfall([ + function(cb) { + agent + .post('/users') + .send(_.cloneDeep(user)) + .end(cb); + }, + function(response, cb) { + agent + .post('/login') + .send({email: user.email, password: user.password}) + .end(cb); + }, + function(response, cb) { + async.times(25, function(n, next) { + gameTemplate = _.cloneDeep(gameTemplate); + gameTemplate.name = 'Game ' + n; + gameTemplate.phase = (n % 2 == 0 ? 'FORMING' : 'STARTED') + agent + .post('/games') + .send(gameTemplate) + .end( function(err, response) { + if (err) { + return next(err); + } + next(); + }); + }, function(err) { + if (err) { + return cb(err); + } + cb(); + }); + } + ], done); + }); + + it('Should create games', function (done) { + agent + .post('/games') + .send(_.cloneDeep(gameTemplate)) + .end( function(err, response) { + if (err) { + return done(err); + } + response.statusCode.should.equal(200); + var game = response.body; + assertGame(game, gameTemplate); + done(); + }); + }); + + it('Should filter by phase', function(done) { + agent + .get('/games?phase=FORMING,COMPLETED') + .end( function(err, response) { + if (err) { + return done(err); + } + response.statusCode.should.equal(200); + var resultSet = response.body; + resultSet.start.should.equal(0); + resultSet.totalCount.should.exist; + resultSet.results.should.have.length(20); + resultSet.results.forEach(function(game) { + game.phase.should.equal('FORMING'); + }); + done(); + }); + }); + + it('Should page', function(done) { + agent + .get('/games?offset=3&limit=10') + .end( function(err, response) { + if (err) { + return done(err); + } + response.statusCode.should.equal(200); + var resultSet = response.body; + resultSet.start.should.equal(3); + resultSet.totalCount.should.exist; + resultSet.results.should.have.length(10); + done(); + }); + }); + + it('Should filter by name', function(done) { + agent + .get('/games?name=Ga%2515') + .end( function(err, response) { + if (err) { + return done(err); + } + response.statusCode.should.equal(200); + var resultSet = response.body; + resultSet.start.should.equal(0); + resultSet.totalCount.should.exist; + expect(resultSet.results.length).to.be.at.least(1); + expect(resultSet.results.length).to.be.at.most(20); + resultSet.results.forEach(function(game) { + game.name.should.equal('Game ' + 15); + }); + done(); + }); + }); +}); diff --git a/test/integration/storytellerTest.js b/test/integration/storytellerTest.js new file mode 100644 index 0000000..9b75d33 --- /dev/null +++ b/test/integration/storytellerTest.js @@ -0,0 +1,313 @@ +var should = require('chai').should(), + request = require('supertest'), + async = require('async'), + io = require('socket.io-client'), + userFactory = require('../factories/user'), + gameFactory = require('../factories/game'), + payloads = require('../../payloads'), + messageTypes = require('../../message-types'), + constants = require('../../constants'), + _ = require('lodash'), + gameState = require('../../app/lib/gameState'), + locales = require('../../locales'), + gameRepository = require('../../app/repositories/game'); + +constants.TICK_HEARTBEAT = 1000; +constants.TICK_LENGTH = 200; + +if (!global.hasOwnProperty('testApp')) { + global.testApp = require('../../server'); +} + +var app = global.testApp; + +describe('Core sockets', function() { + var game = undefined; + var gameTemplate = undefined; + var users = undefined; + var user = undefined; + var agent = request.agent(app.app); + var socket = undefined; + this.timeout(5000); + + var startGame = function() { + for (var index in users) { + player = users[index]; + payload = new payloads.StorytellerJoinInPayload(player, game); + socket.emit('message', { + payload: payload.getPayload() + }); + } + }; + + beforeEach(function(done) { + socket = io.connect('http://localhost:3000', {'force new connection': true}); + async.waterfall([ + function(cb) { + async.times(8, function(n, next) { + user = userFactory.create(); + agent + .post('/users') + .send(user) + .end( function(err, response) { + if (err || response.statusCode != 200) { + return next(err); + } + next(null, response); + }); + }, function(err, responses) { + if (err) { + return cb(err); + } + cb(null, responses); + }); + }, + function(responses, cb) { + users = _.pluck(responses, 'body'); + agent + .post('/login') + .send({email: user.email, password: user.password}) + .end(cb); + }, + function(response, cb) { + agent + .post('/games') + .send(gameFactory.create()) + .end(cb); + } + ], function(err, response) { + if (err) { + return done(err); + } + response.statusCode.should.equal(200); + game = response.body; + done(); + }); + }); + + afterEach(function(done) { + socket.disconnect(); + done(); + }); + + it('Should heartbeat', function (done) { + var expectedCount = 0; + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_HEARTBEATPING) { + if (data.payload.data.count === 2) { + done(); + } + data.payload.data.count.should.equal(expectedCount); + expectedCount++; + data.payload.type.should.equal(messageTypes.STORYTELLER_HEARTBEATPING); + payload = new payloads.StorytellerHeartbeatInPayload({id: game.id}, data.payload.data.count); + socket.emit('message', { + payload: payload.getPayload() + }); + } + }); + }); + + it('Should allow players to join game', function(done) { + var expectedUsers = _.indexBy(users, 'id'); + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_JOINED) { + playerId = data.payload.data.playerId; + game = gameState.getGameById(game.id); + for (var index in game.players) { + player = game.players[index]; + should.exist(player); + } + delete expectedUsers[playerId]; + game.players.length.should.equal(2); + if (Object.keys(expectedUsers).length === 7) { + done(); + } + } + }); + joinPayload = new payloads.StorytellerJoinInPayload(users[0], game); + socket.emit('message', { + payload: joinPayload.getPayload() + }); + joinPayload = new payloads.StorytellerJoinInPayload(users[1], game); + socket.emit('message', { + payload: joinPayload.getPayload() + }); + }); + + it('Should assign roles when a game starts', function(done) { + expectedUsers = _.indexBy(users, 'id'); + + var roles = {}; + roles[constants.PLAYER_ROLE_ACTIVIST] = 1; + roles[constants.PLAYER_ROLE_CITIZEN_ACTIVIST] = 3; + roles[constants.PLAYER_ROLE_CITIZEN_APATHETIC] = 1; + roles[constants.PLAYER_ROLE_JOURNALIST] = 1; + roles[constants.PLAYER_ROLE_AGENT] = 1; + roles[constants.PLAYER_ROLE_CITIZEN_AGENT] = 1; + + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_ROLESET) { + roles[data.payload.data.role]--; + if (roles[data.payload.data.role] === 0) { + delete roles[data.payload.data.role]; + } + delete expectedUsers[data.payload.data.playerId]; + if (_.isEmpty(roles) && _.isEmpty(expectedUsers)) { + return done(); + } + } + }); + startGame(); + }); + + it('Should assign allegiances when a game starts', function(done) { + var expectedUsers = _.indexBy(users, 'id'); + + var allegiances = {}; + allegiances[constants.PLAYER_ALLEGIANCE_GOVERNMENT] = 2; + allegiances[constants.PLAYER_ALLEGIANCE_NEUTRAL] = 2; + allegiances[constants.PLAYER_ALLEGIANCE_REBELLION] = 4; + + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_ALLEGIANCECHANGE) { + allegiances[data.payload.data.allegiance] -= 1; + if (allegiances[data.payload.data.allegiance] === 0) { + delete allegiances[data.payload.data.allegiance]; + } + delete expectedUsers[data.payload.data.playerId]; + if (_.isEmpty(allegiances) && _.isEmpty(expectedUsers)) { + return done(); + } + } + }); + startGame(); + }); + + it('Should assign rumors when a game starts', function(done) { + trueRumors = 0; + falseRumors = 0; + var expectedUsers = _.indexBy(users, 'id'); + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_RUMORRECEIVED) { + rumor = data.payload.data; + should.exist(rumor.text); + rumor.destinationId.should.equal(rumor.sourceId); + rumor.rumorId.should.exist; + rumor.publicationStatus.should.equal(constants.RUMOR_PUBLICATIONSTATUS_UNPUBLISHED); + should.exist(rumor.truthStatus); + switch (rumor.truthStatus) { + case constants.RUMOR_TRUTHSTATUS_TRUE: + trueRumors++; + break; + case constants.RUMOR_TRUTHSTATUS_FALSE: + falseRumors++; + break + } + delete expectedUsers[rumor.sourceId]; + if (_.isEmpty(expectedUsers) && falseRumors === 7 && trueRumors === 1) { + return done(); + } + } + }); + startGame(); + }); + + it('Should subpoena irc', function(done) { + var tick = 0; + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_TOCK) { + tick++; + return; + } + + if (data.payload.type === messageTypes.STORYTELLER_IRCSUBPOENAD) { + tick /= 8; // 8 players receive tick messages + tick.should.equal(constants.TICK_IRCSUBPOENA); + done(); + } + }); + startGame(); + }); + + it('Should subpoena email', function(done) { + var tick = 0; + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_TOCK) { + tick++; + return; + } + + if (data.payload.type === messageTypes.STORYTELLER_EMAILSUBPOENAD) { + tick /= 8; // 8 players receive tick messages + tick.should.equal(constants.TICK_EMAILSUBPOENA); + done(); + } + }); + startGame(); + }); + + it('Should start ticks when a game starts', function(done) { + var expectedCount = 2; + var actualCount = 0; + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_TOCK && ++actualCount === expectedCount) { + done(); + } + }); + startGame(); + }); + + it('Should kill a player', function(done) { + killedEventsReceived = 0; + gameOversReceived = 0; + resultsReceived = 0; + var playerId = undefined; + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_ROLESET && + data.payload.data.role === constants.PLAYER_ROLE_AGENT ) { + playerId = data.payload.data.playerId; + killPayload = new payloads.StorytellerKillInPayload({ id: playerId }, game); + socket.emit('message', { + payload: killPayload.getPayload() + }); + } else if (data.payload.type === messageTypes.STORYTELLER_KILLED) { + data.payload.data.playerId.should.equal(playerId); + killedEventsReceived++; + } else if (data.payload.type === messageTypes.STORYTELLER_ANNOUNCEMENT) { + if (data.payload.data.text === locales['default'].messages.storyteller.VICTORY_ACTIVISTS_KILLING) { + resultsReceived++; + } else if (data.payload.data.text === locales['default'].messages.storyteller.GAMEOVER) { + gameOversReceived++; + } + } else { + // do nothing + } + if (killedEventsReceived === 8 && resultsReceived === 8 && gameOversReceived === 8) { + gameRepository.get(game.id, function(err, game) { + if (err) { + return done(err); + } + game.phase.should.equal('COMPLETED'); + done(); + }); + } + }); + startGame(); + }); + + it('Should start game when enough players have joined', function(done) { + socket.on('message', function(data) { + if (data.payload.type === messageTypes.STORYTELLER_TOCK) { + gameRepository.get(game.id, function(err, game) { + if (err) { + return done(err); + } + game.phase.should.equal('STARTED'); + done(); + }); + } + }); + startGame(); + }); +}); diff --git a/test/integration/userTest.js b/test/integration/userTest.js index f9ab04a..cd1e43a 100644 --- a/test/integration/userTest.js +++ b/test/integration/userTest.js @@ -5,7 +5,10 @@ var async = require('async'); var _ = require('lodash'); var url = 'http://localhost:3000'; -var app = require('../../server'); +if (!global.hasOwnProperty('testApp')) { + global.testApp = require('../../server'); +} +var app = global.testApp; describe('User routes', function() { var user = undefined; @@ -56,10 +59,6 @@ describe('User routes', function() { }); }); - after(function() { - app.server.close(); - }); - it('Should create users', function (done) { var userTemplate = { username: 'dry bones' + Math.random(), @@ -117,7 +116,7 @@ describe('User routes', function() { .post('/login') .send(_.cloneDeep(userTemplate)) .end(cb); - }, + }, function(response, cb) { updatedUser = { username: 'dry bones' + Math.random(), @@ -151,4 +150,4 @@ describe('User routes', function() { done(); }); }); -}); \ No newline at end of file +}); diff --git a/words.js b/words.js new file mode 100644 index 0000000..dd87676 --- /dev/null +++ b/words.js @@ -0,0 +1,250 @@ +exports.words = [ + "ability","able","aboard","about","above","accept","accident","according", + "account","accurate","acres","across","act","action","active","activity", + "actual","actually","add","addition","additional","adjective","adult","adventure", + "advice","affect","afraid","after","afternoon","again","against","age", + "ago","agree","ahead","aid","air","airplane","alike","alive", + "all","allow","almost","alone","along","aloud","alphabet","already", + "also","although","am","among","amount","ancient","angle","angry", + "animal","announced","another","answer","ants","any","anybody","anyone", + "anything","anyway","anywhere","apart","apartment","appearance","apple","applied", + "appropriate","are","area","arm","army","around","arrange","arrangement", + "arrive","arrow","art","article","as","aside","ask","asleep", + "at","ate","atmosphere","atom","atomic","attached","attack","attempt", + "attention","audience","author","automobile","available","average","avoid","aware", + "away","baby","back","bad","badly","bag","balance","ball", + "balloon","band","bank","bar","bare","bark","barn","base", + "baseball","basic","basis","basket","bat","battle","be","bean", + "bear","beat","beautiful","beauty","became","because","become","becoming", + "bee","been","before","began","beginning","begun","behavior","behind", + "being","believed","bell","belong","below","belt","bend","beneath", + "bent","beside","best","bet","better","between","beyond","bicycle", + "bigger","biggest","bill","birds","birth","birthday","bit","bite", + "black","blank","blanket","blew","blind","block","blood","blow", + "blue","board","boat","body","bone","book","border","born", + "both","bottle","bottom","bound","bow","bowl","box","boy", + "brain","branch","brass","brave","bread","break","breakfast","breath", + "breathe","breathing","breeze","brick","bridge","brief","bright","bring", + "broad","broke","broken","brother","brought","brown","brush","buffalo", + "build","building","built","buried","burn","burst","bus","bush", + "business","busy","but","butter","buy","by","cabin","cage", + "cake","call","calm","came","camera","camp","can","canal", + "cannot","cap","capital","captain","captured","car","carbon","card", + "care","careful","carefully","carried","carry","case","cast","castle", + "cat","catch","cattle","caught","cause","cave","cell","cent", + "center","central","century","certain","certainly","chain","chair","chamber", + "chance","change","changing","chapter","character","characteristic","charge","chart", + "check","cheese","chemical","chest","chicken","chief","child","children", + "choice","choose","chose","chosen","church","circle","circus","citizen", + "city","class","classroom","claws","clay","clean","clear","clearly", + "climate","climb","clock","close","closely","closer","cloth","clothes", + "clothing","cloud","club","coach","coal","coast","coat","coffee", + "cold","collect","college","colony","color","column","combination","combine", + "come","comfortable","coming","command","common","community","company","compare", + "compass","complete","completely","complex","composed","composition","compound","concerned", + "condition","congress","connected","consider","consist","consonant","constantly","construction", + "contain","continent","continued","contrast","control","conversation","cook","cookies", + "cool","copper","copy","corn","corner","correct","correctly","cost", + "cotton","could","count","country","couple","courage","course","court", + "cover","cow","cowboy","crack","cream","create","creature","crew", + "crop","cross","crowd","cry","cup","curious","current","curve", + "customs","cut","cutting","daily","damage","dance","danger","dangerous", + "dark","darkness","date","daughter","dawn","day","dead","deal", + "dear","death","decide","declared","deep","deeply","deer","definition", + "degree","depend","depth","describe","desert","design","desk","detail", + "determine","develop","development","diagram","diameter","did","die","differ", + "difference","different","difficult","difficulty","dig","dinner","direct","direction", + "directly","dirt","dirty","disappear","discover","discovery","discuss","discussion", + "disease","dish","distance","distant","divide","division","do","doctor", + "does","dog","doing","doll","dollar","done","donkey","door", + "dot","double","doubt","down","dozen","draw","drawn","dream", + "dress","drew","dried","drink","drive","driven","driver","driving", + "drop","dropped","drove","dry","duck","due","dug","dull", + "during","dust","duty","each","eager","ear","earlier","early", + "earn","earth","easier","easily","east","easy","eat","eaten", + "edge","education","effect","effort","egg","eight","either","electric", + "electricity","element","elephant","eleven","else","empty","end","enemy", + "energy","engine","engineer","enjoy","enough","enter","entire","entirely", + "environment","equal","equally","equator","equipment","escape","especially","essential", + "establish","even","evening","event","eventually","ever","every","everybody", + "everyone","everything","everywhere","evidence","exact","exactly","examine","example", + "excellent","except","exchange","excited","excitement","exciting","exclaimed","exercise", + "exist","expect","experience","experiment","explain","explanation","explore","express", + "expression","extra","eye","face","facing","fact","factor","factory", + "failed","fair","fairly","fall","fallen","familiar","family","famous", + "far","farm","farmer","farther","fast","fastened","faster","fat", + "father","favorite","fear","feathers","feature","fed","feed","feel", + "feet","fell","fellow","felt","fence","few","fewer","field", + "fierce","fifteen","fifth","fifty","fight","fighting","figure","fill", + "film","final","finally","find","fine","finest","finger","finish", + "fire","fireplace","firm","first","fish","five","fix","flag", + "flame","flat","flew","flies","flight","floating","floor","flow", + "flower","fly","fog","folks","follow","food","foot","football", + "for","force","foreign","forest","forget","forgot","forgotten","form", + "former","fort","forth","forty","forward","fought","found","four", + "fourth","fox","frame","free","freedom","frequently","fresh","friend", + "friendly","frighten","frog","from","front","frozen","fruit","fuel", + "full","fully","fun","function","funny","fur","furniture","further", + "future","gain","game","garage","garden","gas","gasoline","gate", + "gather","gave","general","generally","gentle","gently","get","getting", + "giant","gift","girl","give","given","giving","glad","glass", + "globe","go","goes","gold","golden","gone","good","goose", + "got","government","grabbed","grade","gradually","grain","grandfather","grandmother", + "graph","grass","gravity","gray","great","greater","greatest","greatly", + "green","grew","ground","group","grow","grown","growth","guard", + "guess","guide","gulf","gun","habit","had","hair","half", + "halfway","hall","hand","handle","handsome","hang","happen","happened", + "happily","happy","harbor","hard","harder","hardly","has","hat", + "have","having","hay","he","headed","heading","health","heard", + "hearing","heart","heat","heavy","height","held","hello","help", + "helpful","her","herd","here","herself","hidden","hide","high", + "higher","highest","highway","hill","him","himself","his","history", + "hit","hold","hole","hollow","home","honor","hope","horn", + "horse","hospital","hot","hour","house","how","however","huge", + "human","hundred","hung","hungry","hunt","hunter","hurried","hurry", + "hurt","husband","ice","idea","identity","if","ill","image", + "imagine","immediately","importance","important","impossible","improve","in","inch", + "include","including","income","increase","indeed","independent","indicate","individual", + "industrial","industry","influence","information","inside","instance","instant","instead", + "instrument","interest","interior","into","introduced","invented","involved","iron", + "is","island","it","its","itself","jack","jar","jet", + "job","join","joined","journey","joy","judge","jump","jungle", + "just","keep","kept","key","kids","kill","kind","kitchen", + "knew","knife","know","knowledge","known","label","labor","lack", + "lady","laid","lake","lamp","land","language","large","larger", + "largest","last","late","later","laugh","law","lay","layers", + "lead","leader","leaf","learn","least","leather","leave","leaving", + "led","left","leg","length","lesson","let","letter","level", + "library","lie","life","lift","light","like","likely","limited", + "line","lion","lips","liquid","list","listen","little","live", + "living","load","local","locate","location","log","lonely","long", + "longer","look","loose","lose","loss","lost","lot","loud", + "love","lovely","low","lower","luck","lucky","lunch","lungs", + "lying","machine","machinery","mad","made","magic","magnet","mail", + "main","mainly","major","make","making","man","managed","manner", + "manufacturing","many","map","mark","market","married","mass","massage", + "master","material","mathematics","matter","may","maybe","me","meal", + "mean","means","meant","measure","meat","medicine","meet","melted", + "member","memory","men","mental","merely","met","metal","method", + "mice","middle","might","mighty","mile","military","milk","mill", + "mind","mine","minerals","minute","mirror","missing","mission","mistake", + "mix","mixture","model","modern","molecular","moment","money","monkey", + "month","mood","moon","more","morning","most","mostly","mother", + "motion","motor","mountain","mouse","mouth","move","movement","movie", + "moving","mud","muscle","music","musical","must","my","myself", + "mysterious","nails","name","nation","national","native","natural","naturally", + "nature","near","nearby","nearer","nearest","nearly","necessary","neck", + "needed","needle","needs","negative","neighbor","neighborhood","nervous","nest", + "never","new","news","newspaper","next","nice","night","nine", + "no","nobody","nodded","noise","none","noon","nor","north", + "nose","not","note","noted","nothing","notice","noun","now", + "number","numeral","nuts","object","observe","obtain","occasionally","occur", + "ocean","of","off","offer","office","officer","official","oil", + "old","older","oldest","on","once","one","only","onto", + "open","operation","opinion","opportunity","opposite","or","orange","orbit", + "order","ordinary","organization","organized","origin","original","other","ought", + "our","ourselves","out","outer","outline","outside","over","own", + "owner","oxygen","pack","package","page","paid","pain","paint", + "pair","palace","pale","pan","paper","paragraph","parallel","parent", + "park","part","particles","particular","particularly","partly","parts","party", + "pass","passage","past","path","pattern","pay","peace","pen", + "pencil","people","per","percent","perfect","perfectly","perhaps","period", + "person","personal","pet","phrase","physical","piano","pick","picture", + "pictured","pie","piece","pig","pile","pilot","pine","pink", + "pipe","pitch","place","plain","plan","plane","planet","planned", + "planning","plant","plastic","plate","plates","play","pleasant","please", + "pleasure","plenty","plural","plus","pocket","poem","poet","poetry", + "point","pole","police","policeman","political","pond","pony","pool", + "poor","popular","population","porch","port","position","positive","possible", + "possibly","post","pot","potatoes","pound","pour","powder","power", + "powerful","practical","practice","prepare","present","president","press","pressure", + "pretty","prevent","previous","price","pride","primitive","principal","principle", + "printed","private","prize","probably","problem","process","produce","product", + "production","program","progress","promised","proper","properly","property","protection", + "proud","prove","provide","public","pull","pupil","pure","purple", + "purpose","push","put","putting","quarter","queen","question","quick", + "quickly","quiet","quietly","quite","rabbit","race","radio","railroad", + "rain","raise","ran","ranch","range","rapidly","rate","rather", + "raw","rays","reach","read","reader","ready","real","realize", + "rear","reason","recall","receive","recent","recently","recognize","record", + "red","refer","refused","region","regular","related","relationship","religious", + "remain","remarkable","remember","remove","repeat","replace","replied","report", + "represent","require","research","respect","rest","result","return","review", + "rhyme","rhythm","rice","rich","ride","riding","right","ring", + "rise","rising","river","road","roar","rock","rocket","rocky", + "rod","roll","roof","room","root","rope","rose","rough", + "round","route","row","rubbed","rubber","rule","ruler","run", + "running","rush","sad","saddle","safe","safety","said","sail", + "sale","salmon","salt","same","sand","sang","sat","satellites", + "satisfied","save","saved","saw","say","scale","scared","scene", + "school","science","scientific","scientist","score","screen","sea","search", + "season","seat","second","secret","section","see","seed","seeing", + "seems","seen","seldom","select","selection","sell","send","sense", + "sent","sentence","separate","series","serious","serve","service","sets", + "setting","settle","settlers","seven","several","shade","shadow","shake", + "shaking","shall","shallow","shape","share","sharp","she","sheep", + "sheet","shelf","shells","shelter","shine","shinning","ship","shirt", + "shoe","shoot","shop","shore","short","shorter","shot","should", + "shoulder","shout","show","shown","shut","sick","sides","sight", + "sign","signal","silence","silent","silk","silly","silver","similar", + "simple","simplest","simply","since","sing","single","sink","sister", + "sit","sitting","situation","six","size","skill","skin","sky", + "slabs","slave","sleep","slept","slide","slight","slightly","slip", + "slipped","slope","slow","slowly","small","smaller","smallest","smell", + "smile","smoke","smooth","snake","snow","so","soap","social", + "society","soft","softly","soil","solar","sold","soldier","solid", + "solution","solve","some","somebody","somehow","someone","something","sometime", + "somewhere","son","song","soon","sort","sound","source","south", + "southern","space","speak","special","species","specific","speech","speed", + "spell","spend","spent","spider","spin","spirit","spite","split", + "spoken","sport","spread","spring","square","stage","stairs","stand", + "standard","star","stared","start","state","statement","station","stay", + "steady","steam","steel","steep","stems","step","stepped","stick", + "stiff","still","stock","stomach","stone","stood","stop","stopped", + "store","storm","story","stove","straight","strange","stranger","straw", + "stream","street","strength","stretch","strike","string","strip","strong", + "stronger","struck","structure","struggle","stuck","student","studied","studying", + "subject","substance","success","successful","such","sudden","suddenly","sugar", + "suggest","suit","sum","summer","sun","sunlight","supper","supply", + "support","suppose","sure","surface","surprise","surrounded","swam","sweet", + "swept","swim","swimming","swing","swung","syllable","symbol","system", + "table","tail","take","taken","tales","talk","tall","tank", + "tape","task","taste","taught","tax","tea","teach","teacher", + "team","tears","teeth","telephone","television","tell","temperature","ten", + "tent","term","terrible","test","than","thank","that","thee", + "them","themselves","then","theory","there","therefore","these","they", + "thick","thin","thing","think","third","thirty","this","those", + "thou","though","thought","thousand","thread","three","threw","throat", + "through","throughout","throw","thrown","thumb","thus","thy","tide", + "tie","tight","tightly","till","time","tin","tiny","tip", + "tired","title","to","tobacco","today","together","told","tomorrow", + "tone","tongue","tonight","too","took","tool","top","topic", + "torn","total","touch","toward","tower","town","toy","trace", + "track","trade","traffic","trail","train","transportation","trap","travel", + "treated","tree","triangle","tribe","trick","tried","trip","troops", + "tropical","trouble","truck","trunk","truth","try","tube","tune", + "turn","twelve","twenty","twice","two","type","typical","uncle", + "under","underline","understanding","unhappy","union","unit","universe","unknown", + "unless","until","unusual","up","upon","upper","upward","us", + "use","useful","using","usual","usually","valley","valuable","value", + "vapor","variety","various","vast","vegetable","verb","vertical","very", + "vessels","victory","view","village","visit","visitor","voice","volume", + "vote","vowel","voyage","wagon","wait","walk","wall","want", + "war","warm","warn","was","wash","waste","watch","water", + "wave","way","we","weak","wealth","wear","weather","week", + "weigh","weight","welcome","well","went","were","west","western", + "wet","whale","what","whatever","wheat","wheel","when","whenever", + "where","wherever","whether","which","while","whispered","whistle","white", + "who","whole","whom","whose","why","wide","widely","wife", + "wild","will","willing","win","wind","window","wing","winter", + "wire","wise","wish","with","within","without","wolf","women", + "won","wonder","wonderful","wood","wooden","wool","word","wore", + "work","worker","world","worried","worry","worse","worth","would", + "wrapped","write","writer","writing","written","wrong","wrote","yard", + "year","yellow","yes","yesterday","yet","you","young","younger", + "your","yourself","youth","zero","zoo" +]; + +exports.articles = []; +exports.nouns = []; +exports.verbs = [];