diff --git a/COMMANDS b/COMMANDS new file mode 100644 index 00000000..e41b2f1e --- /dev/null +++ b/COMMANDS @@ -0,0 +1,2 @@ + terser --compress --output nodegame-full.min.js -- nodegame-full.js + diff --git a/conf/http.js b/conf/http.js index b7b96b28..4d69e5a8 100644 --- a/conf/http.js +++ b/conf/http.js @@ -1,6 +1,6 @@ /** * # http.js - * Copyright(c) 2020 Stefano Balietti + * Copyright(c) 2023 Stefano Balietti * MIT Licensed * * Configuration file for Express server in nodegame-server @@ -245,9 +245,15 @@ function configure(app, servernode) { let listOfGames = J.keys(gamesObj); // Remove aliases. let filteredGames = listOfGames.filter(function(name) { - return (!gamesObj[name].disabled && !gamesObj[name].errored && - (!gamesObj[name].alias || - gamesObj[name].alias.indexOf(name) === -1)); + // WAS: + // return (!gamesObj[name].disabled && !gamesObj[name].errored && + // (!gamesObj[name].alias || + // gamesObj[name].alias.indexOf(name) === -1)); + let g = gamesObj[name]; + if (g.disabled || g.errored) return false; + if (g.info.card === false) return false; + if (g.alias && g.alias.indexOf(name) !== -1) return false; + return true; }); if (J.isArray(servernode.homePage.cardsOrder)) { filteredGames = @@ -267,15 +273,20 @@ function configure(app, servernode) { let i = 0; for (let j = 0; j < filteredGames.length; j++) { let name = filteredGames[j]; - if (i >= colors.length) i = 0; - let color = colors[i]; // Mixout name and description from package.json // if not in card, or if no card is defined. let card = J.mixout(gamesObj[name].info.card || {}, { name: name.charAt(0).toUpperCase() + name.slice(1), description: gamesObj[name].info.description }); + + if (i >= colors.length) i = 0; + let color = card.color || colors[i]; + games.push({ + // If someone rename `card.name` the link still needs + // to point to name. + _name: name, name: card.name, color: color, url: card.url, diff --git a/lib/ChannelRegistry.js b/lib/ChannelRegistry.js index 67a1ed12..8a85c4e6 100644 --- a/lib/ChannelRegistry.js +++ b/lib/ChannelRegistry.js @@ -11,6 +11,8 @@ // ## Global scope module.exports = ChannelRegistry; +const crypto = require("crypto"); + const ngc = require('nodegame-client'); const J = ngc.JSUS; @@ -131,7 +133,7 @@ function ChannelRegistry(options) { * @see ChannelRegistry.addClient */ ChannelRegistry.prototype.generateClientId = function() { - return J.uniqueKey(this.getClients()); + return crypto.randomBytes(16).toString("base64"); }; /** diff --git a/lib/GameRouter.js b/lib/GameRouter.js index 12e2dc0a..094d8147 100644 --- a/lib/GameRouter.js +++ b/lib/GameRouter.js @@ -134,85 +134,90 @@ GameRouter.prototype.addRoutes = function(alias) { maxAge: gameInfo.channel.cacheMaxAge })); } - // app.use(express.static(gameDir + 'public')); // Auth. if (gameInfo.auth && gameInfo.auth.enabled) { auth = gameInfo.auth; - // claimId - if (auth.claimId) { - app.get(name + '/claimid/*', function(req, res, next) { - var userId, valid, code; - var cb; - cb = req.query.callback || 'callback'; - userId = req.query.id; + if (auth.mode !== 'monitor') { - // Send it. - res.set('Content-Type', 'application/javascript'); - res.set('Charset', 'utf-8'); + // claimId + if (auth.claimId) { - // Claim Id can be disabled while the game runs, check again! - if (!auth.claimId) { - return replyToClaimId(res, cb, 400, 'operation disabled', - auth.claimIdModifyReply); - } + app.get(name + '/claimid/*', function(req, res, next) { + var userId, valid, code; + var cb; + cb = req.query.callback || 'callback'; + userId = req.query.id; - if (!userId || userId.trim() === '') { - return replyToClaimId(res, cb, 400, 'no code provided', - auth.claimIdModifyReply); - } + // Send it. + res.set('Content-Type', 'application/javascript'); + res.set('Charset', 'utf-8'); - if ('function' === typeof auth.claimIdValidateRequest) { - valid = auth.claimIdValidateRequest(req.query, req.headers); + // ClaimId can be disabled while the game runs, check again! + if (!auth.claimId) { + return replyToClaimId(res, cb, 400, + 'operation disabled', + auth.claimIdModifyReply); + } - if (valid !== true) { - return replyToClaimId(res, cb, 400, valid || 'error'); + if (!userId || userId.trim() === '') { + return replyToClaimId(res, cb, 400, 'no code provided', + auth.claimIdModifyReply); } - } - // Ask the channel for next available code. - code = channel.registry.claimId(userId); + if ('function' === typeof auth.claimIdValidateRequest) { + valid = auth.claimIdValidateRequest(req.query, + req.headers); - if (code) { - if ('function' === typeof auth.claimIdPostProcess) { - auth.claimIdPostProcess(code, req.query, - req.headers); + if (valid !== true) { + return replyToClaimId(res, cb, 400, + valid || 'error'); + } } - return replyToClaimId(res, cb, 200, code.id, - auth.claimIdModifyReply, code); - } - else { - return replyToClaimId(res, cb, 400, 'no more codes', - auth.claimIdModifyReply); - } - - }); - } - // TODO: Monitor route was before auth, now moved in alias-if. + // Ask the channel for next available code. + code = channel.registry.claimId(userId); - app.get(name + '/auth/*', function(req, res, next) { - var userId, pwd, tmp; - tmp = req.params[0].split('/'); - userId = tmp[0]; - pwd = tmp[1]; + if (code) { + if ('function' === typeof auth.claimIdPostProcess) { + auth.claimIdPostProcess(code, req.query, + req.headers); + } + return replyToClaimId(res, cb, 200, code.id, + auth.claimIdModifyReply, code); + } + else { + return replyToClaimId(res, cb, 400, 'no more codes', + auth.claimIdModifyReply); + } - // External mode. - if (auth.mode === 'external') { - let client = channel.registry.getClient(userId); - if (!client) channel.registry.addClient(userId, { pwd: pwd }); + }); } - login(that, name, userId, pwd, res, req); - }); + // First check authorization and set cookies. - // If not alias, add monitor route and - // optimize auth check for alias and not alias. - if (!alias) { + // Authorization for players. + app.get(name + '/auth/*', function(req, res, next) { + var userId, pwd, tmp; + tmp = req.params[0].split('/'); + userId = tmp[0]; + pwd = tmp[1]; + // External mode. + if (auth.mode === 'external') { + let client = channel.registry.getClient(userId); + if (!client) channel.registry.addClient(userId, { + pwd: pwd + }); + } + + login(that, name, userId, pwd, res, req); + }); + + // Authorization for Monitor. app.get(name + '/monitor/auth/*', function(req, res) { var userId, pwd, tmp; tmp = req.params[0].split('/'); @@ -221,37 +226,77 @@ GameRouter.prototype.addRoutes = function(alias) { loginSuper(that, name, userId, pwd, res, req); }); - app.get(name + '/*', function(req, res, next) { - if (req.params[0] && - req.params[0].substring(0,8) === 'monitor/') { + // If not alias, add monitor route. + // Cookie-checking must be include monitor cookie and player cookie. + if (!alias) { - if (!req.cookies || - !superTokens[req.cookies.nodegame_token]) { + app.get(name + '/*', function(req, res, next) { + if (req.params[0] && + req.params[0].substring(0,8) === 'monitor/') { + + if (!req.cookies || + !superTokens[req.cookies.nodegame_token]) { + + res.status(403).send(unauthMsg); + return; + } + } + // Check if a cookie is set correctly. + else if (!req.cookies || + !tokens[req.cookies.nodegame_token]) { res.status(403).send(unauthMsg); return; } - } - // Check if a cookie is set correctly. - else if (!req.cookies || !tokens[req.cookies.nodegame_token]) { - res.status(403).send(unauthMsg); - return; - } - // Auth OK. - next(); - }); + // Auth OK. + next(); + }); + } + + // Cookie-checking includes only player cookie. + else { + app.get(name + '/*', function(req, res, next) { + if (!req.cookies || !tokens[req.cookies.nodegame_token]) { + res.status(403).send(unauthMsg); + return; + } + // Auth OK. + next(); + }); + } } + + // Monitor mode. else { - app.get(name + '/*', function(req, res, next) { - if (!req.cookies || !tokens[req.cookies.nodegame_token]) { - res.status(403).send(unauthMsg); - return; - } - // Auth OK. - next(); - }); - } + if (!alias) { + + app.get(name + '/monitor/auth/*', function(req, res) { + var userId, pwd, tmp; + tmp = req.params[0].split('/'); + userId = tmp[0]; + pwd = tmp[1]; + loginSuper(that, name, userId, pwd, res, req); + }); + + app.get(name + '/*', function(req, res, next) { + // Duplicated as above. + if (req.params[0] && + req.params[0].substring(0,8) === 'monitor/') { + + if (!req.cookies || + !superTokens[req.cookies.nodegame_token]) { + res.status(403).send(unauthMsg); + return; + } + } + + // Auth OK. + next(); + }); + + } + } } // Monitor (no monitor in aliases). @@ -280,7 +325,7 @@ GameRouter.prototype.addRoutes = function(alias) { return false; } that.storedRequests[idx] = null; - serveFiles(res, files, gameInfo, + serveFiles(req, res, files, gameInfo, file => path.join(gameInfo.dir, 'data', file) ); }); @@ -370,32 +415,35 @@ GameRouter.prototype.addRoutes = function(alias) { // Game. (default index.htm). app.get(name + '/*', function(req, res) { var filePath, file, headers; - var decoded, clientId, token; + // var decoded, clientId, token; file = req.params[0]; if ('' === file || 'undefined' === typeof file) file = 'index.htm'; - if (gameInfo.channel.noAuthCookie) { - - if (req.cookies) { - if (req.cookies.nodegame_token) { - decoded = verifyToken(req.cookies.nodegame_token, - channel.secret); - } - - // If no cookie is found, or if the channel session is - // not correct, generates a new ID and sets the cookie. - if (!decoded || (decoded.session !== channel.session)) { - clientId = channel.registry.generateClientId(); - token = createToken(clientId, channel, tokens); - res.cookie('nodegame_token', token, { - path: '/', - httpOnly: true - }); - } - } - } + doNoAuthCookie(req, res, channel, tokens, gameInfo); + + // if (gameInfo.channel.noAuthCookie) { + // + // if (req.cookies) { + // + // if (req.cookies.nodegame_token) { + // decoded = verifyToken(req.cookies.nodegame_token, + // channel.secret); + // } + // + // // If no cookie is found, or if the channel session is + // // not correct, generates a new ID and sets the cookie. + // if (!decoded || (decoded.session !== channel.session)) { + // clientId = channel.registry.generateClientId(); + // token = createToken(clientId, channel, tokens); + // res.cookie('nodegame_token', token, { + // path: '/', + // httpOnly: true + // }); + // } + // } + // } // Build filePath to file in public directory. filePath = path.join(gameInfo.dir, 'public', file); @@ -423,7 +471,9 @@ GameRouter.prototype.addRoutes = function(alias) { // If it is not text, it was not cached. if (headers['Content-Type'].substring(0,4) !== 'text') { - res.sendFile(filePath); + res.sendFile(filePath, function(err) { + if (err) do404(req, res, gameInfo); + }); return; } @@ -465,7 +515,7 @@ GameRouter.prototype.addRoutes = function(alias) { // Template not existing. if (templateFound === false) { - res.status(404).send('File not Found'); + do404(req, res, gameInfo); } // Template existing, render it. else if (templateFound === true) { @@ -480,7 +530,7 @@ GameRouter.prototype.addRoutes = function(alias) { resourceManager.inTemplates(gameName, templatePath, false); - res.status(404).send('File not found'); + do404(req, res, gameInfo); return; } resourceManager.inTemplates(gameName, templatePath, true); @@ -715,7 +765,7 @@ function renderTemplate(resourceManager, req, res, channel, ' ' + e.stack); // TODO: Log error. // TODO: Mark file as non-existing ? - res.status(404).send('File not Found'); + do404(req, res, info); return; } resourceManager.cacheContextCallback(gameName, contextPath, cb); @@ -837,16 +887,16 @@ function serveFileOrDie(req, res, gameInfo, cb) { let file = getFileFromReq(req, res); if (!file) return; - serveFiles(res, [file], gameInfo, cb); + serveFiles(req, res, [file], gameInfo, cb); } -function serveFiles(res, files, gameInfo, cb) { +function serveFiles(req, res, files, gameInfo, cb) { if (files.length === 1 && files[0] !== '*') { let file = files[0]; let filePath = getFilePath(file, cb); fs.exists(filePath, function(exists) { if (!exists) { - res.status(404).send('File not found: ' + file); + do404(req, res, gameInfo); return; } res.attachment(path.basename(file)); @@ -986,3 +1036,48 @@ function verifyToken(token, secret) { return false; } } + +/** + * ### do404 + * + * Verifies a signed JSON web token + * + * @param {object} req The request object + * @param {object} res The response object + * @param {object} gameInfo The info about the game + */ +function do404(req, res, gameInfo) { + let file = gameInfo.channel.page404; + if (file) { + res.sendFile(path.join(gameInfo.dir, 'public', file)); + } + else { + res.status(404).send('Resource not found.'); + } +} + + +function doNoAuthCookie(req, res, channel, tokens, gameInfo) { + + if (gameInfo.channel.noAuthCookie) { + let decoded; + if (req.cookies) { + + if (req.cookies.nodegame_token) { + decoded = verifyToken(req.cookies.nodegame_token, + channel.secret); + } + + // If no cookie is found, or if the channel session is + // not correct, generates a new ID and sets the cookie. + if (!decoded || (decoded.session !== channel.session)) { + let clientId = channel.registry.generateClientId(); + let token = createToken(clientId, channel, tokens); + res.cookie('nodegame_token', token, { + path: '/', + httpOnly: true + }); + } + } + } +} diff --git a/lib/GameServer.js b/lib/GameServer.js index 26a2e9b2..ea9378b5 100644 --- a/lib/GameServer.js +++ b/lib/GameServer.js @@ -584,7 +584,9 @@ GameServer.prototype.onConnect = function(socketId, socketObj, handshake, // also for socket.io connections. if (socketObj.name !== 'direct') { res = this.channel.gameInfo.auth; - if (res && res.enabled && (!clientId || invalidSessionCookie)) { + if (res && res.enabled && res.mode !== 'monitor' && + (!clientId || invalidSessionCookie)) { + // Warns and disconnect client if auth failed. this.disposeUnauthorizedClient(socketId, socketObj); return false; diff --git a/lib/rooms/GameRoom.js b/lib/rooms/GameRoom.js index 0bb9b4ff..5c2192cf 100644 --- a/lib/rooms/GameRoom.js +++ b/lib/rooms/GameRoom.js @@ -370,6 +370,9 @@ function GameRoom(config) { reconOptions.plot.timer = resetTime; } + // Session. + reconOptions.session = node.game.session.player(p.id); + reconCb = this.plot.getProperty(curStage, 'reconnect'); if (reconCb === true) { @@ -381,6 +384,9 @@ function GameRoom(config) { } else if ('function' === typeof reconCb) { + + // reconOptions.game = {}; + // Res contains the reconnect options for the step, // or false to abort reconnection. res = reconCb.call(this, code, reconOptions); @@ -389,6 +395,9 @@ function GameRoom(config) { disposeClient(node, p); return; } + // console.log(reconOptions); + // if (J.isEmpty(reconOptions.game)) reconOptions.game = null; + // console.log(reconOptions); } // Add player to player list. @@ -420,18 +429,22 @@ function GameRoom(config) { (() => { - let gameStages; + let gameStages, mySetup; // Get the right stages. if (this.gameLevel) { + // Setup is not cloned. + mySetup = this.game.levels[this.gameLevel].setup; + gameStages = this.game.levels[this.gameLevel].gameStages; } else { + // Setup is not cloned. + mySetup = this.game.setup; + gameStages = this.game.gameStages; } - // Setup is not cloned. - let mySetup = this.game.setup; // Settings are cloned. let gameSettings = J.clone(this.gameTreatment); @@ -767,7 +780,7 @@ GameRoom.prototype.stopGame = function(doLogic, clientList, force) { * @return {object} The built client type */ GameRoom.prototype.getClientType = function(type, mixinConf, gameNode) { - var game, settings, stager, setup; + var game, settings, stager; var properties, stepRule; if (!this.clientTypes[type]) { @@ -866,7 +879,9 @@ GameRoom.prototype.getClientType = function(type, mixinConf, gameNode) { } // Setup is not cloned. - setup = this.game.setup; + let setup = this.gameLevel ? + this.game.levels[this.gameLevel].setup : this.game.setup; + // Settings are cloned. settings = J.clone(this.gameTreatment); @@ -1024,7 +1039,7 @@ GameRoom.prototype.updateWin = function(id, update, opts = {}) { if (!client) return false; if ('number' !== typeof update) { throw new TypeError('GameRoom.updateWin: update must be number. ' + - 'Found: ' + win); + 'Found: ' + update); } let clear = opts.clear || false; let win = opts.winProperty || 'win'; @@ -1235,6 +1250,12 @@ GameRoom.prototype.computeBonus = function(options) { // END MODS. } + if (!clients || !clients.length) { + channel.sysLogger.log('GameRoom.computeBonus: no clients to compute ' + + 'bonus.', 'warn'); + return; + } + if ('undefined' !== typeof options.header) { if (!J.isArray(options.header) || !options.header.length) { diff --git a/lib/rooms/WaitingRoom.js b/lib/rooms/WaitingRoom.js index ca2f799a..4f35b746 100644 --- a/lib/rooms/WaitingRoom.js +++ b/lib/rooms/WaitingRoom.js @@ -67,8 +67,10 @@ WaitingRoom.treatmentCallbacks = { // ### random treatment_random: 'Selects a random treatment (with re-sampling)', // ### latin_square + treatment_weighted_random: 'Randomly samples treatments based on weights', + // ### latin_square treatment_latin_square: 'Rotates across all treatments maximizing ' + - 'randomness in the sequence.' + 'randomness in the sequence' }; /** @@ -143,6 +145,13 @@ function WaitingRoom(config) { */ this.numberOfDispatches = 0; + /** + * ### WaitingRoom.numberOfDispatchesByTreatment + * + * List that keeps track of how many dispatches per treatment + */ + this.numberOfDispatchesByTreatment = {}; + /** * ### WaitingRoom.ROTATION_OFFSET * @@ -184,10 +193,23 @@ function WaitingRoom(config) { /** * ### WaitingRoom.DISCONNECT_IF_NOT_SELECTED * - * Boolean or null, indicating whether unselected players are disconnected + * If TRUE, clients are disconnected if not selected for dispatch */ this.DISCONNECT_IF_NOT_SELECTED = null; + /** + * ### WaitingRoom.NOTIFY_UPDATES + * + * If TRUE, clients are notified about some updates + * + * Updates: + * - changes in num of players (connections/disconnections) + * - not being selected for a dispatch + * + * Default: TRUE + */ + this.NOTIFY_UPDATES = true; + /** * ### WaitingRoom.timeOuts * @@ -321,6 +343,17 @@ function WaitingRoom(config) { */ this.OVERWRITE_CHOSEN_TREATMENT = null; + /** + * ### WaitingRoom.OVERWRITE_DISPATCH_GROUP + * + * If set, it overwrites the group of players for the next dispatch only + * + * It is set to null at every dispatch. + * + * @see WaitingRoom.ALLOW_SELECT_TREATMENT + */ + this.OVERWRITE_DISPATCH_GROUP = null; + /** * ### WaitingRoom.PLAYER_SORTING * @@ -365,6 +398,25 @@ function WaitingRoom(config) { */ this.PING_DISPATCH_ANYWAY = false; + /** + * ### WaitingRoom.ALLOW_PLAY_WITH_BOTS + * + * If TRUE, users can trigger dispatch from waiting room + * + * @see ALLOW_PLAY_WITH_BOTS + */ + this.ALLOW_PLAY_WITH_BOTS = false; + + + /** + * ### WaitingRoom.ALLOW_SELECT_TREATMENT + * + * If TRUE, users can select the treatment for the dispatch + * + * @see ALLOW_PLAY_WITH_BOTS + */ + this.ALLOW_SELECT_TREATMENT = false; + /** * ### WaitingRoom.REMOTE_DISPATCH * @@ -385,6 +437,21 @@ function WaitingRoom(config) { */ this.REMOTE_DISPATCH = false; + /** + * ### WaitingRoom.TREATMENT_DISPLAY_CB + * + * A callback sent to the client that displays the treatments + * + * Format: + * + * ```js + * function (treatment, description, index, waitroomWidget) { + * return treatment + ": " + description; + * } + * ``` + */ + this.TREATMENT_DISPLAY_CB = null; + /** * ### WaitingRoom.TEXTS * @@ -478,6 +545,8 @@ function WaitingRoom(config) { o = { treatment_random: WaitingRoom.treatmentCallbacks['treatment_random'], + treatment_weighted_random: + WaitingRoom.treatmentCallbacks['treatment_weighted_random'], treatment_rotate: WaitingRoom.treatmentCallbacks['treatment_rotate'], treatment_latin_square: @@ -490,6 +559,29 @@ function WaitingRoom(config) { } return o; })(this); + + /** + * ### WaitingRoom.weightedTreatments + * + * Array of indexes to treatment built according to weights + */ + this.weightedTreatments = null; + + /** + * ### WaitingRoom.treatmentWeights + * + * Weights for every treatment + */ + this.treatmentWeights = null; + + /** + * ### WaitingRoom.weightedTreatments + * + * List of max number of treatments to be launched by this waitroom + */ + this.treatmentQuotas = null; + + } /** @@ -503,10 +595,17 @@ function WaitingRoom(config) { */ WaitingRoom.prototype.dispatchWithBots = function(justConnect) { var neededBots, botOptions; - var i; + var i, pList; + + if (this.OVERWRITE_DISPATCH_GROUP) { + pList = this.OVERWRITE_DISPATCH_GROUP; + } + else { + pList = this.clients.player; + } // Fill rest of group with bots. - neededBots = this.POOL_SIZE - this.clients.player.db.length; + neededBots = this.POOL_SIZE - pList.db.length; // If no bots are necessary (e.g., when pool size is 1) just dispatch. if (neededBots < 1) { @@ -550,7 +649,7 @@ WaitingRoom.prototype.dispatchWithBots = function(justConnect) { * @param {object} settings Configuration object */ WaitingRoom.prototype.parseSettings = function(settings) { - var d, where, gameName; + var d, where, gameName, tmp; var that = this; gameName = this.channel.gameInfo.info.name + ' '; @@ -604,7 +703,7 @@ WaitingRoom.prototype.parseSettings = function(settings) { if ('undefined' !== typeof settings.NOTIFY_INTERVAL) { if ('number' === typeof settings.NOTIFY_INTERVAL) { - checkPositiveInteger(settings, 'NOTIFY_INTRVAL', where); + checkPositiveInteger(settings, 'NOTIFY_INTERVAL', where); this.NOTIFY_INTERVAL = settings.NOTIFY_INTERVAL; } else { @@ -614,6 +713,18 @@ WaitingRoom.prototype.parseSettings = function(settings) { } } + if ('undefined' !== typeof settings.NOTIFY_UPDATES) { + if ('boolean' === typeof settings.NOTIFY_UPDATES) { + this.NOTIFY_UPDATES = + settings.NOTIFY_UPDATES; + } + else { + throw new TypeError(where + 'NOTIFY_UPDATES must ' + + 'be boolean or undefined. Found: ' + + settings.NOTIFY_UPDATES); + } + } + if ('undefined' !== typeof settings.DISCONNECT_IF_NOT_SELECTED) { if ('boolean' === typeof settings.DISCONNECT_IF_NOT_SELECTED) { this.DISCONNECT_IF_NOT_SELECTED = @@ -622,11 +733,10 @@ WaitingRoom.prototype.parseSettings = function(settings) { else { throw new TypeError(where + 'DISCONNECT_IF_NOT_SELECTED must ' + 'be boolean or undefined. Found: ' + - settings.DISCONNECT_IF_NOT_SELECTED ); + settings.DISCONNECT_IF_NOT_SELECTED); } } - if ('undefined' !== typeof settings.DISPATCH_TO_SAME_ROOM) { if ('boolean' === typeof settings.DISPATCH_TO_SAME_ROOM) { this.DISPATCH_TO_SAME_ROOM = settings.DISPATCH_TO_SAME_ROOM; @@ -717,9 +827,18 @@ WaitingRoom.prototype.parseSettings = function(settings) { } else { throw new TypeError(where + 'ROTATION_OFFSET must be a positive ' + - 'number or undefined. Found: ' + - settings.ROTATION_OFFSET); + 'number or undefined. Found: ' + + settings.ROTATION_OFFSET); + } + } + + if (this.CHOSEN_TREATMENT === 'treatment_weighted_random') { + if ('object' !== typeof settings.TREATMENT_WEIGHTS) { + throw new TypeError(where + 'TREATMENT_WEIGHTS must be object ' + + 'Found: ' + settings.TREATMENT_WEIGHTS); } + + this.treatmentWeights = settings.TREATMENT_WEIGHTS; } if ('undefined' !== typeof settings.PLAYER_SORTING) { @@ -852,6 +971,24 @@ WaitingRoom.prototype.parseSettings = function(settings) { this.ALLOW_SELECT_TREATMENT = false; } + // TODO: Check this. + if ('undefined' !== typeof settings.ALLOW_QUERYSTRING_TREATMENT) { + this.ALLOW_QUERYSTRING_TREATMENT = + !!settings.ALLOW_QUERYSTRING_TREATMENT; + } + else { + this.ALLOW_QUERYSTRING_TREATMENT = false; + } + + tmp = settings.TREATMENT_DISPLAY_CB; + if ('undefined' !== typeof tmp) { + if ('function' !== typeof tmp) { + throw new TypeError(where + 'TREATMENT_DISPLAY_CB must ' + + 'be function. Found: ' + tmp); + } + this.TREATMENT_DISPLAY_CB = tmp; + } + this.node.on.data('PLAYWITHBOT', function(msg) { var pid, t, logStr; @@ -885,6 +1022,13 @@ WaitingRoom.prototype.parseSettings = function(settings) { logStr += ' with treatment=' + t; } + if (that.GROUP_SIZE === 1) { + // Simulating a PlayerList object without the need to create one. + that.OVERWRITE_DISPATCH_GROUP = { db: [ { + id: msg.from, sid: '' + } ] }; + } + that.channel.sysLogger.log(logStr); that.dispatchWithBots(); @@ -981,6 +1125,8 @@ WaitingRoom.prototype.makeWidgetConfig = function() { // chosenTreatment: this.CHOSEN_TREATMENT, playWithBotOption: this.ALLOW_PLAY_WITH_BOTS, selectTreatmentOption: this.ALLOW_SELECT_TREATMENT, + queryStringDispatch: this.ALLOW_QUERYSTRING_TREATMENT, + treatmentDisplayCb: this.TREATMENT_DISPLAY_CB }; if (this.ALLOW_SELECT_TREATMENT) { o.availableTreatments = this.availableTreatments; @@ -1077,7 +1223,7 @@ WaitingRoom.prototype.kickUnresponsivePlayers = function(callback, options) { that = this; if (this.isPingInProgress) { - console.log('Warning: Suspending call to ' + where + + this.channel.sysLogger.log('Warning: Suspending call to ' + where + ' because kicking is still in progress.'); this.suspendedPings.push([callback, options]); return; @@ -1111,8 +1257,15 @@ WaitingRoom.prototype.kickUnresponsivePlayers = function(callback, options) { // Setup variables for pinging all players. + // If a pList was passed to dispach, we ping only these players. + if (options.callbackArgs.pList) { + players = options.callbackArgs.pList.db; + } + else { + players = this.clients.player.db; + } + // Manual copy of player list. - players = this.clients.player.db; pList = new PlayerList(); pLen = players.length; // Need to update pcounter because we use .insert below. @@ -1183,7 +1336,7 @@ WaitingRoom.prototype.kickUnresponsivePlayers = function(callback, options) { continue; } - console.log('Pinging player ' + p.id, timeToWaitForPing); + this.channel.sysLogger.log('Ping player ' + p.id + timeToWaitForPing); that.node.get('PING', pingReturnedCb, p.id, { timeout: timeToWaitForPing, executeOnce: true, @@ -1240,7 +1393,7 @@ WaitingRoom.prototype.dispatch = (function() { }; parseGroupSizeOptions = function(opts) { - var groupSize, numberOfGames, nPlayers; + var groupSize, numberOfGames, nPlayers, pList; opts = opts || {}; if ('undefined' !== typeof opts.groupSize) { @@ -1255,10 +1408,13 @@ WaitingRoom.prototype.dispatch = (function() { groupSize = this.GROUP_SIZE; } + pList = opts.pList || this.clients.player; + // If nothing is specified, try to dispatch maximal amount of games. numberOfGames = opts.numberOfGames; if ('undefined' === typeof numberOfGames) { - nPlayers = this.clients.player.size(); + // pList can be a simulate object, so we directly check .length. + nPlayers = pList.db.length; if (!nPlayers || this.MAX_WAIT_TIME && this.EXECUTION_MODE === 'TIMEOUT') { @@ -1319,7 +1475,11 @@ WaitingRoom.prototype.dispatch = (function() { // players have not been tested, therefore "kick" must pass // the list of tested players. pList = opts.pList || this.clients.player; - nPlayers = pList.size(); + // In case the player is participating in a single-player task, + // that.OVERWRITE_DISPATCH_GROUP = [ msg.from ]; is an array. + + + nPlayers = pList.db.length; groupSize = opts.groupSize; numberOfGames = opts.numberOfGames; @@ -1353,6 +1513,9 @@ WaitingRoom.prototype.dispatch = (function() { if (nPlayers > groupSize) { if (this.PLAYER_GROUPING) { + // Note: OVERWRITE_DISPATCH_GROUP simulates a PlayerList + // object, but it should never enter here, because it is + // only for GROUP_SIZE=1. groups = this.PLAYER_GROUPING(pList, numberOfGames); nGroupsCreated = groups.length; @@ -1381,6 +1544,10 @@ WaitingRoom.prototype.dispatch = (function() { } else { + // Note: OVERWRITE_DISPATCH_GROUP simulates a PlayerList + // object, but it should never enter here, because it is + // only for GROUP_SIZE=1. + // Shuffle player list anyway. dispatchList = pList.shuffle().db; // Sort according to priority. @@ -1392,16 +1559,14 @@ WaitingRoom.prototype.dispatch = (function() { } } else { - // was: - // dispatchList = pList.breed().db; dispatchList = pList.db.slice(); } this.setDispatchState(WaitingRoom.dispatchStates.DISPATCHING); - console.log('DISPATCH: ', + sysLogger.log('DISPATCH: ' + ( nPlayersToDispatch + '/' + nPlayers, ('string' === typeof chosenTreatment ? - chosenTreatment : 'Function')); + chosenTreatment : 'Function'))); // If we have too many players log a warning. if (nPlayers > nPlayersToDispatch) { @@ -1522,6 +1687,11 @@ WaitingRoom.prototype.dispatch = (function() { // One more dispatch done! ++this.numberOfDispatches; + // Keep count of which treatment was dispatched. + this.numberOfDispatchesByTreatment[treatmentName] = + this.numberOfDispatchesByTreatment[treatmentName] ? + ++this.numberOfDispatchesByTreatment[treatmentName] : 1; + // Close waitingRoom if next game would not be dispatchable. if (!this.shouldDispatchMoreGames()) { this.closeRoom(); @@ -1539,35 +1709,42 @@ WaitingRoom.prototype.dispatch = (function() { // Close room, if requested. if (closeAfterDispatch) { - this.setDispatchState(WaitingRoom.dispatchStates.NONE); + // Ste: commented this line, because it is set at the very end. + // this.setDispatchState(WaitingRoom.dispatchStates.NONE); this.closeRoom(); this.closeAfterDispatch = false; } - this.setDispatchState(WaitingRoom.dispatchStates.NOTIFYING); // Tell players still waiting, that they have not been selected. - pList = this.clients.player; - nPlayers = pList.size(); - for (i = 0; i < nPlayers; ++i) { - pId = pList.db[i].id; + if (this.NOTIFY_UPDATES) { - // Increment timesNotSelected for player. - if (pList.db[i].timesNotSelected) ++pList.db[i].timesNotSelected; - else pList.db[i].timesNotSelected = 1; + this.setDispatchState(WaitingRoom.dispatchStates.NOTIFYING); - if (this.makeClientTimeOuts) this.clearTimeOut(pId); + pList = this.clients.player; + nPlayers = pList.size(); + for (i = 0; i < nPlayers; ++i) { + pId = pList.db[i].id; - code = channel.registry.getClient(pId); - this.node.say('DISPATCH', pId, { - action: 'notSelected', - exit: code.ExitCode, - shouldDispatchMoreGames: this.shouldDispatchMoreGames() - }); + // Increment timesNotSelected for player. + if (pList.db[i].timesNotSelected) { + ++pList.db[i].timesNotSelected; + } + else { + pList.db[i].timesNotSelected = 1; + } + + if (this.makeClientTimeOuts) this.clearTimeOut(pId); + + code = channel.registry.getClient(pId); + this.node.say('DISPATCH', pId, { + action: 'notSelected', + exit: code.ExitCode, + shouldDispatchMoreGames: this.shouldDispatchMoreGames() + }); - // Ste was: (but it does not make any sense). - // Exit loop. - // if (!this.shouldDispatchMoreGames()) break + } } + this.setDispatchState(WaitingRoom.dispatchStates.NONE); if (this.ON_DISPATCHED) this.ON_DISPATCHED(this, opts); @@ -1590,12 +1767,12 @@ WaitingRoom.prototype.dispatch = (function() { // Have we dispatched 'em all? if (!checkShouldDispatch.call(this)) return; - // TODO: check here. - // Originally numberOfGames was set in waitroom.js. - // Number of games requested. - //if (!opts.numberOfGames) { - // opts.numberOfGames = Math.floor(this.POOL_SIZE / this.GROUP_SIZE); - //} + // Must come before parseGroupSizeOptions. + // If a group was set, copy it into settings and delete it. + if (this.OVERWRITE_DISPATCH_GROUP) { + opts.pList = this.OVERWRITE_DISPATCH_GROUP; + this.OVERWRITE_DISPATCH_GROUP = null; + } // Fix group size and number of groups. // In case of kickUnresponsivePlayers, these can change in between. @@ -1606,6 +1783,7 @@ WaitingRoom.prototype.dispatch = (function() { opts.chosenTreatment = this.OVERWRITE_CHOSEN_TREATMENT; this.OVERWRITE_CHOSEN_TREATMENT = null; } + // If we have pool of 1 and mode == WAIT_FOR_N_PLAYERS just go ahead! if (!this.PING_BEFORE_DISPATCH || (this.EXECUTION_MODE === 'WAIT_FOR_N_PLAYERS' && @@ -1631,6 +1809,8 @@ WaitingRoom.prototype.dispatch = (function() { */ WaitingRoom.prototype.notifyPlayerUpdate = function(np) { var that; + if (!this.NOTIFY_UPDATES) return; + if ('number' === typeof np) { this.node.say('PLAYERSCONNECTED', 'ROOM', np); if (this.notifyTimeout) { @@ -1724,6 +1904,9 @@ WaitingRoom.prototype.clearTimeOut = function(playerID) { WaitingRoom.prototype.decideTreatment = function(t, idxInBatch) { var treatments, tLen; treatments = this.channel.gameInfo.treatmentNames; + + // treatments = this.checkQuotas(treatments); + tLen = treatments.length; if (t === 'treatment_rotate') { @@ -1747,6 +1930,76 @@ WaitingRoom.prototype.decideTreatment = function(t, idxInBatch) { // console.log(idx); return treatments[idx]; } + if (t === 'treatment_weighted_random') { + if (!this.weightedTreatments) { + // Expand the treatments array according to weights. + this.weightedTreatments = []; + // Sum of weights (used to normalize weights). + let sum = 0; + + // Treatments not specified in the weights array, divide equally + // remainder of 1 - sum of weights. + let leftOverTreatments = []; + + treatments.forEach((t, idx) => { + + let w = this.treatmentWeights[t]; + if ('undefined' === typeof w) { + leftOverTreatments.push(t); + } + else { + if (false === J.isNumber(w, 0, null, true)) { + throw new TypeError('WaitingRoom.decideTreatment: ' + + 'invalid treatment weight for ' + + t + ': ' + w); + } + sum += w; + } + }); + + if (sum === 0) { + throw new Error('WaitingRoom.decideTreatment: sum of ' + + 'weights is zero'); + } + + if (sum < 1 && leftOverTreatments.length) { + + // Leftover from sum. + let l = 1 - sum; + + // Divide leftover weight across other treatments. + leftOverTreatments.forEach((t, idx) => { + this.treatmentWeights[t] = l / leftOverTreatments.length; + }); + + // Set sum to 1. + sum = 1; + } + + // For every treatment compute the cumulative density function. + let cdf = 0; + treatments.forEach((t, idx) => { + // Normalize weight. + let w = this.treatmentWeights[t]; + // Not found means zero weights. + w = w ? w / sum : 0; + cdf += w; + this.weightedTreatments[idx] = cdf; + }); + + // Force the last one to be 1. + this.weightedTreatments[this.weightedTreatments.length - 1] = 1; + } + + // Select a random treatment with weight, searching in cdf. + let idx = 0; + let rnd = Math.random(); + for (; idx < this.weightedTreatments.length; idx++) { + if (rnd <= this.weightedTreatments[idx]) break; + } + + return treatments[idx]; + } if ('treatment_random' === t || 'undefined' === typeof t) { return treatments[J.randomInt(-1, tLen-1)]; } @@ -1769,6 +2022,29 @@ WaitingRoom.prototype.decideTreatment = function(t, idxInBatch) { return t; }; +// /** +// * ## WaitingRoom.checkQuotas +// * +// * Check if string, or use it. +// * +// * @param {mixed} t String, object, function or undefined +// * used to decide a treatment +// * +// * @return {string} Chosen treatment +// */ +// WaitingRoom.prototype.checkQuotas = function(treatments) { +// if (!this.treatmentQuotas) return treatments; +// // Make a copy of array. +// let t = treatments.slice(0); +// if ('function' === this.treatmentQuotas) { +// t = this.treatmentQuotas(this.numberOfDispatchesByTreatment, t); +// } +// else if ('object' === typeof this.treatmentQuotas) { +// +// } +// +// }; + /** * ### WaitingRoom.abortDispatch * diff --git a/lib/servers/PlayerServer.js b/lib/servers/PlayerServer.js index a323bb91..4da6d3d6 100644 --- a/lib/servers/PlayerServer.js +++ b/lib/servers/PlayerServer.js @@ -286,6 +286,10 @@ PlayerServer.prototype.attachCustomListeners = function() { that.notifyRoomDisconnection(player, room); }); + // #### say.DATA + // Listens on say.DATA messages. + this.on(say + 'SESSION', this.standardPlayerMsg); + // #### shutdown // Listens on server shutdown this.sio.sockets.on('shutdown', function(message) { diff --git a/lib/sockets/SocketIo.js b/lib/sockets/SocketIo.js index dc9ddf4b..b5c6ddb7 100644 --- a/lib/sockets/SocketIo.js +++ b/lib/sockets/SocketIo.js @@ -103,14 +103,15 @@ SocketIo.prototype.attachListeners = function() { that = this; this.sioChannel = this.sio.of(this.gameServer.endpoint).on('connection', function(socket) { - var res, prefixedSid; - var startingRoom, clientType; + // console.log('hello!', socket.handshake.decoded_token.name); - prefixedSid = that.sidPrefix + socket.id; + let opts = that.gameServer.options; + let prefixedSid = that.sidPrefix + socket.id; - if (that.gameServer.options.sioQuery && socket.handshake.query) { + let startingRoom, clientType; + if (opts.sioQuery && socket.handshake.query) { startingRoom = socket.handshake.query.startingRoom; clientType = socket.handshake.query.clientType; @@ -129,11 +130,16 @@ SocketIo.prototype.attachListeners = function() { } } + // if (opts.collectIp) { + // console.log(socket.request.headers['x-forwarded-for']); + // console.log(socket.request.connection.remoteAddress); + // console.log(socket.request.headers.referer); + // } // Add information about the IP in the headers. // This might change in different versions of Socket.IO // socket.handshake.headers.address = socket.handshake.address; - res = that.gameServer.onConnect(prefixedSid, that, + let res = that.gameServer.onConnect(prefixedSid, that, socket.handshake, clientType, startingRoom); diff --git a/package.json b/package.json index bbef65d2..0751985d 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "nodegame-monitor": "*", "request": "2.88.0", "shelf.js": ">= 0.3.7", - "socket.io": "4.1.3", + "socket.io": "4.4.1", "winston": "3.3.3" }, "devDependencies": { diff --git a/public/javascripts/nodegame-full-optimized.js b/public/javascripts/nodegame-full-optimized.js new file mode 100644 index 00000000..d21124b6 --- /dev/null +++ b/public/javascripts/nodegame-full-optimized.js @@ -0,0 +1,57573 @@ +/** + * # nodeGame IE support + * Copyright(c) 2015 Stefano Balietti + * MIT Licensed + * + * Shims of methods required by nodeGame, but missing in old IE browsers + * + * --- + */ + +if ('undefined' === typeof String.prototype.trim) { + String.prototype.trim = function() { + return this.replace(/^\s+|\s+$/g, ''); + }; +} + +if ('undefined' === typeof console) { + this.console = {log: function() {}}; +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/ +// Global_Objects/Date/now +if (!Date.now) { + Date.now = function now() { + return new Date().getTime(); + }; +} + +// http://stackoverflow.com/questions/2790001/ +// fixing-javascript-array-functions-in-internet-explorer-indexof-foreach-etc +if (!('indexOf' in Array.prototype)) { + Array.prototype.indexOf= function(find, i /*opt*/) { + if (i===undefined) i= 0; + if (i<0) i+= this.length; + if (i<0) i= 0; + for (var n= this.length; ithis.length-1) i= this.length-1; + for (i++; i-->0;) /* i++ because from-argument is sadly inclusive */ + if (i in this && this[i]===find) + return i; + return -1; + }; +} + +if (typeof Object.create !== 'function') { + Object.create = (function() { + var Temp = function() {}; + return function (prototype) { + if (arguments.length > 1) { + throw Error('Second argument not supported'); + } + if (typeof prototype != 'object') { + throw TypeError('Argument must be an object'); + } + Temp.prototype = prototype; + var result = new Temp(); + Temp.prototype = null; + return result; + }; + })(); +} + +/** + JSON2 + http://www.JSON.org/json2.js + 2011-02-23 +*/ + +var JSON; +if (!JSON) { + JSON = {}; +} + +(function () { + "use strict"; + + var global = new Function('return this')() + , JSON = global.JSON + ; + + if (!JSON) { + JSON = {}; + } + + function f(n) { + // Format integers to have at least two digits. + return n < 10 ? '0' + n : n; + } + + if (typeof Date.prototype.toJSON !== 'function') { + + Date.prototype.toJSON = function (key) { + + return isFinite(this.valueOf()) ? + this.getUTCFullYear() + '-' + + f(this.getUTCMonth() + 1) + '-' + + f(this.getUTCDate()) + 'T' + + f(this.getUTCHours()) + ':' + + f(this.getUTCMinutes()) + ':' + + f(this.getUTCSeconds()) + 'Z' : null; + }; + + String.prototype.toJSON = + Number.prototype.toJSON = + Boolean.prototype.toJSON = function (key) { + return this.valueOf(); + }; + } + + var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, + gap, + indent, + meta = { // table of character substitutions + '\b': '\\b', + '\t': '\\t', + '\n': '\\n', + '\f': '\\f', + '\r': '\\r', + '"' : '\\"', + '\\': '\\\\' + }, + rep; + + + function quote(string) { + +// If the string contains no control characters, no quote characters, and no +// backslash characters, then we can safely slap some quotes around it. +// Otherwise we must also replace the offending characters with safe escape +// sequences. + + escapable.lastIndex = 0; + return escapable.test(string) ? '"' + string.replace(escapable, function (a) { + var c = meta[a]; + return typeof c === 'string' ? c : + '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }) + '"' : '"' + string + '"'; + } + + + function str(key, holder) { + +// Produce a string from holder[key]. + + var i, // The loop counter. + k, // The member key. + v, // The member value. + length, + mind = gap, + partial, + value = holder[key]; + +// If the value has a toJSON method, call it to obtain a replacement value. + + if (value && typeof value === 'object' && + typeof value.toJSON === 'function') { + value = value.toJSON(key); + } + +// If we were called with a replacer function, then call the replacer to +// obtain a replacement value. + + if (typeof rep === 'function') { + value = rep.call(holder, key, value); + } + +// What happens next depends on the value's type. + + switch (typeof value) { + case 'string': + return quote(value); + + case 'number': + +// JSON numbers must be finite. Encode non-finite numbers as null. + + return isFinite(value) ? String(value) : 'null'; + + case 'boolean': + case 'null': + +// If the value is a boolean or null, convert it to a string. Note: +// typeof null does not produce 'null'. The case is included here in +// the remote chance that this gets fixed someday. + + return String(value); + +// If the type is 'object', we might be dealing with an object or an array or +// null. + + case 'object': + +// Due to a specification blunder in ECMAScript, typeof null is 'object', +// so watch out for that case. + + if (!value) { + return 'null'; + } + +// Make an array to hold the partial results of stringifying this object value. + + gap += indent; + partial = []; + +// Is the value an array? + + if (Object.prototype.toString.apply(value) === '[object Array]') { + +// The value is an array. Stringify every element. Use null as a placeholder +// for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || 'null'; + } + +// Join all of the elements together, separated with commas, and wrap them in +// brackets. + + v = partial.length === 0 ? '[]' : gap ? + '[\n' + gap + partial.join(',\n' + gap) + '\n' + mind + ']' : + '[' + partial.join(',') + ']'; + gap = mind; + return v; + } + +// If the replacer is an array, use it to select the members to be stringified. + + if (rep && typeof rep === 'object') { + length = rep.length; + for (i = 0; i < length; i += 1) { + if (typeof rep[i] === 'string') { + k = rep[i]; + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + } else { + +// Otherwise, iterate through all of the keys in the object. + + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + } + +// Join all of the member texts together, separated with commas, +// and wrap them in braces. + + v = partial.length === 0 ? '{}' : gap ? + '{\n' + gap + partial.join(',\n' + gap) + '\n' + mind + '}' : + '{' + partial.join(',') + '}'; + gap = mind; + return v; + } + } + +// If the JSON object does not yet have a stringify method, give it one. + + if (typeof JSON.stringify !== 'function') { + JSON.stringify = function (value, replacer, space) { + +// The stringify method takes a value and an optional replacer, and an optional +// space parameter, and returns a JSON text. The replacer can be a function +// that can replace values, or an array of strings that will select the keys. +// A default replacer method can be provided. Use of the space parameter can +// produce text that is more easily readable. + + var i; + gap = ''; + indent = ''; + +// If the space parameter is a number, make an indent string containing that +// many spaces. + + if (typeof space === 'number') { + for (i = 0; i < space; i += 1) { + indent += ' '; + } + +// If the space parameter is a string, it will be used as the indent string. + + } else if (typeof space === 'string') { + indent = space; + } + +// If there is a replacer, it must be a function or an array. +// Otherwise, throw an error. + + rep = replacer; + if (replacer && typeof replacer !== 'function' && + (typeof replacer !== 'object' || + typeof replacer.length !== 'number')) { + throw new Error('JSON.stringify'); + } + +// Make a fake root object containing our value under the key of ''. +// Return the result of stringifying the value. + + return str('', {'': value}); + }; + } + + +// If the JSON object does not yet have a parse method, give it one. + + if (typeof JSON.parse !== 'function') { + JSON.parse = function (text, reviver) { + +// The parse method takes a text and an optional reviver function, and returns +// a JavaScript value if the text is a valid JSON text. + + var j; + + function walk(holder, key) { + +// The walk method is used to recursively walk the resulting structure so +// that modifications can be made. + + var k, v, value = holder[key]; + if (value && typeof value === 'object') { + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = walk(value, k); + if (v !== undefined) { + value[k] = v; + } else { + delete value[k]; + } + } + } + } + return reviver.call(holder, key, value); + } + + +// Parsing happens in four stages. In the first stage, we replace certain +// Unicode characters with escape sequences. JavaScript handles many characters +// incorrectly, either silently deleting them, or treating them as line endings. + + text = String(text); + cx.lastIndex = 0; + if (cx.test(text)) { + text = text.replace(cx, function (a) { + return '\\u' + + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }); + } + +// In the second stage, we run the text against regular expressions that look +// for non-JSON patterns. We are especially concerned with '()' and 'new' +// because they can cause invocation, and '=' because it can cause mutation. +// But just to be safe, we want to reject all unexpected forms. + +// We split the second stage into 4 regexp operations in order to work around +// crippling inefficiencies in IE's and Safari's regexp engines. First we +// replace the JSON backslash pairs with '@' (a non-JSON character). Second, we +// replace all simple value tokens with ']' characters. Third, we delete all +// open brackets that follow a colon or comma or that begin the text. Finally, +// we look to see that the remaining characters are only whitespace or ']' or +// ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval. + + if (/^[\],:{}\s]*$/ + .test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@') + .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']') + .replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { + +// In the third stage we use the eval function to compile the text into a +// JavaScript structure. The '{' operator is subject to a syntactic ambiguity +// in JavaScript: it can begin a block or an object literal. We wrap the text +// in parens to eliminate the ambiguity. + + j = eval('(' + text + ')'); + +// In the optional fourth stage, we recursively walk the new structure, passing +// each name/value pair to a reviver function for possible transformation. + + return typeof reviver === 'function' ? + walk({'': j}, '') : j; + } + +// If the text is not JSON parseable, then a SyntaxError is thrown. + + throw new SyntaxError('JSON.parse'); + }; + } + + global.JSON = JSON; +}()); + + +// Production steps of ECMA-262, Edition 5, 15.4.4.14 +// Reference: http://es5.github.io/#x15.4.4.14 +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function(searchElement, fromIndex) { + + var k; + + // 1. Let O be the result of calling ToObject passing + // the this value as the argument. + if (this == null) { + throw new TypeError('"this" is null or not defined'); + } + + var O = Object(this); + + // 2. Let lenValue be the result of calling the Get + // internal method of O with the argument "length". + // 3. Let len be ToUint32(lenValue). + var len = O.length >>> 0; + + // 4. If len is 0, return -1. + if (len === 0) { + return -1; + } + + // 5. If argument fromIndex was passed let n be + // ToInteger(fromIndex); else let n be 0. + var n = +fromIndex || 0; + + if (Math.abs(n) === Infinity) { + n = 0; + } + + // 6. If n >= len, return -1. + if (n >= len) { + return -1; + } + + // 7. If n >= 0, then Let k be n. + // 8. Else, n<0, Let k be len - abs(n). + // If k is less than 0, then let k be 0. + k = Math.max(n >= 0 ? n : len - Math.abs(n), 0); + + // 9. Repeat, while k < len + while (k < len) { + // a. Let Pk be ToString(k). + // This is implicit for LHS operands of the in operator + // b. Let kPresent be the result of calling the + // HasProperty internal method of O with argument Pk. + // This step can be combined with c + // c. If kPresent is true, then + // i. Let elementK be the result of calling the Get + // internal method of O with the argument ToString(k). + // ii. Let same be the result of applying the + // Strict Equality Comparison Algorithm to + // searchElement and elementK. + // iii. If same is true, return k. + if (k in O && O[k] === searchElement) { + return k; + } + k++; + } + return -1; + }; +} + +/** + * # JSUS: JavaScript UtilS. + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * Collection of general purpose javascript functions. JSUS helps! + * + * See README.md for extra help. + * --- + */ +(function(exports) { + + var JSUS = exports.JSUS = {}; + + // ## JSUS._classes + // Reference to all the extensions + JSUS._classes = {}; + + // Make sure that the console is available also in old browser, e.g. < IE8. + if ('undefined' === typeof console) console = {}; + if ('undefined' === typeof console.log) console.log = function() {}; + + /** + * ## JSUS.log + * + * Reference to standard out, by default `console.log` + * + * Override to redirect the standard output of all JSUS functions. + * + * @param {string} txt Text to output + */ + JSUS.log = function(txt) { console.log(txt); }; + + /** + * ## JSUS.extend + * + * Extends JSUS with additional methods and or properties + * + * The first parameter can be an object literal or a function. + * A reference of the original extending object is stored in + * JSUS._classes + * + * If a second parameter is passed, that will be the target of the + * extension. + * + * @param {object} additional Text to output + * @param {object|function} target The object to extend + * + * @return {object|function} target The extended object + */ + JSUS.extend = function(additional, target) { + var name, prop; + if ('object' !== typeof additional && + 'function' !== typeof additional) { + return target; + } + + // If we are extending JSUS, store a reference + // of the additional object into the hidden + // JSUS._classes object; + if ('undefined' === typeof target) { + target = target || this; + if ('function' === typeof additional) { + name = additional.toString(); + name = name.substr('function '.length); + name = name.substr(0, name.indexOf('(')); + } + // Must be object. + else { + name = additional.constructor || + additional.__proto__.constructor; + } + if (name) { + this._classes[name] = additional; + } + } + + for (prop in additional) { + if (additional.hasOwnProperty(prop)) { + if (typeof target[prop] !== 'object') { + target[prop] = additional[prop]; + } else { + JSUS.extend(additional[prop], target[prop]); + } + } + } + + // Additional is a class (Function) + // TODO: this is true also for {} + if (additional.prototype) { + JSUS.extend(additional.prototype, target.prototype || target); + } + + return target; + }; + + /** + * ## JSUS.require + * + * Returns a copy/reference of one/all the JSUS components + * + * @param {string} component The name of the requested JSUS library. + * If undefined, all JSUS components are returned. Default: undefined. + * @param {boolean} clone Optional. If TRUE, the requested component + * is cloned before being returned. Default: TRUE + * + * @return {function|boolean} The copy of the JSUS component, or + * FALSE if the library does not exist, or cloning is not possible + */ + JSUS.require = function(component, clone) { + var out; + clone = 'undefined' === typeof clone ? true : clone; + if (clone && 'undefined' === typeof JSUS.clone) { + JSUS.log('JSUS.require: JSUS.clone not found, but clone ' + + 'requested. Cannot continue.'); + return false; + } + if ('undefined' === typeof component) { + out = JSUS._classes; + } + else { + out = JSUS._classes[component] + if ('undefined' === typeof out) { + JSUS.log('JSUS.require: could not find component ' + component); + return false; + } + } + return clone ? JSUS.clone(out) : out; + }; + + /** + * ## JSUS.isNodeJS + * + * Returns TRUE when executed inside Node.JS environment + * + * @return {boolean} TRUE when executed inside Node.JS environment + */ + JSUS.isNodeJS = function() { + return 'undefined' !== typeof module && + 'undefined' !== typeof module.exports && + 'function' === typeof require; + }; + + // ## Node.JS includes + if (JSUS.isNodeJS()) { + require('./lib/compatibility'); + require('./lib/obj'); + require('./lib/array'); + require('./lib/time'); + require('./lib/eval'); + require('./lib/dom'); + require('./lib/random'); + require('./lib/parse'); + require('./lib/queue'); + require('./lib/fs'); + } + else { + // Exports J in the browser. + exports.J = exports.JSUS; + } + +})( + 'undefined' !== typeof module && 'undefined' !== typeof module.exports ? + module.exports: window +); + +/** + * # COMPATIBILITY + * + * Copyright(c) 2015 Stefano Balietti + * MIT Licensed + * + * Tests browsers ECMAScript 5 compatibility + * + * For more information see http://kangax.github.com/es5-compat-table/ + */ +(function(JSUS) { + "use strict"; + + function COMPATIBILITY() {} + + /** + * ## COMPATIBILITY.compatibility + * + * Returns a report of the ECS5 features available + * + * Useful when an application routinely performs an operation + * depending on a potentially unsupported ECS5 feature. + * + * Transforms multiple try-catch statements in a if-else + * + * @return {object} support The compatibility object + */ + COMPATIBILITY.compatibility = function() { + + var support = {}; + + try { + Object.defineProperty({}, "a", {enumerable: false, value: 1}); + support.defineProperty = true; + } + catch(e) { + support.defineProperty = false; + } + + try { + eval('({ get x(){ return 1 } }).x === 1'); + support.setter = true; + } + catch(err) { + support.setter = false; + } + + try { + var value; + eval('({ set x(v){ value = v; } }).x = 1'); + support.getter = true; + } + catch(err) { + support.getter = false; + } + + return support; + }; + + + JSUS.extend(COMPATIBILITY); + +})('undefined' !== typeof JSUS ? JSUS : module.parent.exports.JSUS); + +/** + * # ARRAY + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * Collection of static functions to manipulate arrays + */ +(function(JSUS) { + + "use strict"; + + function ARRAY() {} + + /** + * ## ARRAY.filter + * + * Add the filter method to ARRAY objects in case the method is not + * supported natively. + * + * @see https://developer.mozilla.org/en/JavaScript/Reference/ + * Global_Objects/ARRAY/filter + */ + if (!Array.prototype.filter) { + Array.prototype.filter = function(fun /*, thisp */) { + if (this === void 0 || this === null) throw new TypeError(); + + var t = new Object(this); + var len = t.length >>> 0; + if (typeof fun !== "function") throw new TypeError(); + + var res = []; + var thisp = arguments[1]; + for (var i = 0; i < len; i++) { + if (i in t) { + var val = t[i]; // in case fun mutates this + if (fun.call(thisp, val, i, t)) { + res.push(val); + } + } + } + return res; + }; + } + + /** + * ## ARRAY.isArray + * + * Returns TRUE if a variable is an Array + * + * This method is exactly the same as `Array.isArray`, + * but it works on a larger share of browsers. + * + * @param {object} o The variable to check. + * + * @see Array.isArray + */ + ARRAY.isArray = (function(f) { + if ('function' === typeof f) return f; + else return function(o) { + if (!o) return false; + return Object.prototype.toString.call(o) === '[object Array]'; + }; + })(Array.isArray); + + /** + * ## ARRAY.seq + * + * Returns an array of sequential numbers from start to end + * + * If start > end the series goes backward. + * + * The distance between two subsequent numbers can be controlled + * by the increment parameter. + * + * When increment is not a divider of Abs(start - end), end will + * be missing from the series. + * + * A callback function to apply to each element of the sequence + * can be passed as fourth parameter. + * + * Returns FALSE, in case parameters are incorrectly specified + * + * @param {number} start The first element of the sequence + * @param {number} end The last element of the sequence + * @param {number} increment Optional. The increment between two + * subsequents element of the sequence + * @param {Function} func Optional. A callback function that can modify + * each number of the sequence before returning it + * + * @return {array} The final sequence + */ + ARRAY.seq = function(start, end, increment, func) { + var i, out; + if ('number' !== typeof start) return false; + if (start === Infinity) return false; + if ('number' !== typeof end) return false; + if (end === Infinity) return false; + // TODO: increment zero might be fine if start=end. Check. + if (increment === 0) return false; + if (!JSUS.inArray(typeof increment, ['undefined', 'number'])) { + return false; + } + if (start === end) { + if (!func) return [ start ]; + return [ func(start) ]; + } + increment = increment || 1; + func = func || function(e) {return e;}; + + i = start; + out = []; + + if (start < end) { + while (i <= end) { + out.push(func(i)); + i = i + increment; + } + } + else { + while (i >= end) { + out.push(func(i)); + i = i - increment; + } + } + + return out; + }; + + /** + * ## ARRAY.each + * + * Executes a callback on each element of the array + * + * If an error occurs returns FALSE. + * + * @param {array} array The array to loop in + * @param {Function} cb The callback for each element in the array + * @param {object} context Optional. The context of execution of the + * callback. Defaults ARRAY.each + */ + ARRAY.each = function(array, cb, context) { + var i, len; + if ('object' !== typeof array) { + throw new TypeError('ARRAY.each: array must be object. Found: ' + + array); + } + if ('function' !== typeof cb) { + throw new TypeError('ARRAY.each: cb must be function. Found: ' + + cb); + } + + context = context || this; + len = array.length; + for (i = 0 ; i < len; i++) { + cb.call(context, array[i], i); + } + }; + + /** + * ## ARRAY.map + * + * Executes a callback to each element of the array and returns the result + * + * Any number of additional parameters can be passed after the + * callback function. + * + * @return {array} The result of the mapping execution + * + * @see ARRAY.each + */ + ARRAY.map = function() { + var i, len, args, out, o; + var array, func; + + array = arguments[0]; + func = arguments[1]; + + if (!ARRAY.isArray(array)) { + JSUS.log('ARRAY.map: first parameter must be array. Found: ' + + array); + return; + } + if ('function' !== typeof func) { + JSUS.log('ARRAY.map: second parameter must be function. Found: ' + + func); + return; + } + + len = arguments.length; + if (len === 3) args = [ null, arguments[2] ]; + else if (len === 4) args = [ null, arguments[2], arguments[3] ]; + else { + len = len - 1; + args = new Array(len); + for (i = 1; i < (len); i++) { + args[i] = arguments[i+1]; + } + } + + out = [], len = array.length; + for (i = 0; i < len; i++) { + args[0] = array[i]; + o = func.apply(this, args); + if ('undefined' !== typeof o) out.push(o); + } + return out; + }; + + + /** + * ## ARRAY.removeElement + * + * Removes an element from the the array, and returns it + * + * For objects, deep equality comparison is performed + * through JSUS.equals. + * + * If no element is removed returns FALSE. + * + * @param {mixed} needle The element to search in the array + * @param {array} haystack The array to search in + * + * @return {mixed} The element that was removed, FALSE if none was removed + * + * @see JSUS.equals + */ + ARRAY.removeElement = function(needle, haystack) { + var func, i; + if ('undefined' === typeof needle || !haystack) return false; + + if ('object' === typeof needle) { + func = JSUS.equals; + } + else { + func = function(a, b) { + return (a === b); + }; + } + + for (i = 0; i < haystack.length; i++) { + if (func(needle, haystack[i])){ + return haystack.splice(i,1); + } + } + return false; + }; + + /** + * ## ARRAY.inArray + * + * Returns TRUE if the element is contained in the array, + * FALSE otherwise + * + * For objects, deep equality comparison is performed + * through JSUS.equals. + * + * @param {mixed} needle The element to search in the array + * @param {array} haystack The array to search in + * + * @return {boolean} TRUE, if the element is contained in the array + * + * @see JSUS.equals + */ + ARRAY.inArray = function(needle, haystack) { + var func, i, len; + if (!haystack) return false; + func = JSUS.equals; + len = haystack.length; + for (i = 0; i < len; i++) { + if (func.call(this, needle, haystack[i])) { + return true; + } + } + return false; + }; + + ARRAY.in_array = function(needle, haystack) { + console.log('***ARRAY.in_array is deprecated. ' + + 'Use ARRAY.inArray instead.***'); + return ARRAY.inArray(needle, haystack); + }; + + /** + * ## ARRAY.getNGroups + * + * Returns an array of N array containing the same number of elements + * If the length of the array and the desired number of elements per group + * are not multiple, the last group could have less elements + * + * The original array is not modified. + * + * @see ARRAY.getGroupsSizeN + * @see ARRAY.generateCombinations + * @see ARRAY.matchN + * + * @param {array} array The array to split in subgroups + * @param {number} N The number of subgroups + * + * @return {array} Array containing N groups + */ + ARRAY.getNGroups = function(array, N) { + return ARRAY.getGroupsSizeN(array, Math.floor(array.length / N)); + }; + + /** + * ## ARRAY.getGroupsSizeN + * + * Returns an array of arrays containing N elements each + * + * The last group could have less elements + * + * @param {array} array The array to split in subgroups + * @param {number} N The number of elements in each subgroup + * + * @return {array} Array containing groups of size N + * + * @see ARRAY.getNGroups + * @see ARRAY.generateCombinations + * @see ARRAY.matchN + */ + ARRAY.getGroupsSizeN = function(array, N) { + + var copy = array.slice(0); + var len = copy.length; + var originalLen = copy.length; + var result = []; + + // Init values for the loop algorithm. + var i, idx; + var group = [], count = 0; + for (i=0; i < originalLen; i++) { + + // Get a random idx between 0 and array length. + idx = Math.floor(Math.random()*len); + + // Prepare the array container for the elements of a new group. + if (count >= N) { + result.push(group); + count = 0; + group = []; + } + + // Insert element in the group. + group.push(copy[idx]); + + // Update. + copy.splice(idx,1); + len = copy.length; + count++; + } + + // Add any remaining element. + if (group.length > 0) { + result.push(group); + } + + return result; + }; + + /** + * ## ARRAY._latinSquare + * + * Generate a random Latin Square of size S + * + * If N is defined, it returns "Latin Rectangle" (SxN) + * + * A parameter controls for self-match, i.e. whether the symbol "i" + * is found or not in in column "i". + * + * @api private + * @param {number} S The number of rows + * @param {number} Optional. N The number of columns. Defaults N = S + * @param {boolean} Optional. If TRUE self-match is allowed. Defaults TRUE + * + * @return {array} The resulting latin square (or rectangle) + */ + ARRAY._latinSquare = function(S, N, self) { + self = ('undefined' === typeof self) ? true : self; + // Infinite loop. + if (S === N && !self) return false; + var seq = []; + var latin = []; + for (var i=0; i< S; i++) { + seq[i] = i; + } + + var idx = null; + + var start = 0; + var limit = S; + var extracted = []; + if (!self) limit = S-1; + + for (i=0; i < N; i++) { + do { + idx = JSUS.randomInt(start,limit); + } + while (JSUS.inArray(idx, extracted)); + extracted.push(idx); + + if (idx == 1) { + latin[i] = seq.slice(idx); + latin[i].push(0); + } + else { + latin[i] = seq.slice(idx).concat(seq.slice(0,(idx))); + } + + } + + return latin; + }; + + /** + * ## ARRAY.latinSquare + * + * Generate a random Latin Square of size S + * + * If N is defined, it returns "Latin Rectangle" (SxN) + * + * @param {number} S The number of rows + * @param {number} Optional. N The number of columns. Defaults N = S + * + * @return {array} The resulting latin square (or rectangle) + */ + ARRAY.latinSquare = function(S, N) { + if (!N) N = S; + if (!S || S < 0 || (N < 0)) return false; + if (N > S) N = S; + + return ARRAY._latinSquare(S, N, true); + }; + + /** + * ## ARRAY.latinSquareNoSelf + * + * Generate a random Latin Square of size Sx(S-1), where + * in each column "i", the symbol "i" is not found + * + * If N < S, it returns a "Latin Rectangle" (SxN) + * + * @param {number} S The number of rows + * @param {number} Optional. N The number of columns. Defaults N = S-1 + * + * @return {array} The resulting latin square (or rectangle) + */ + ARRAY.latinSquareNoSelf = function(S, N) { + if (!N) N = S-1; + if (!S || S < 0 || (N < 0)) return false; + if (N > S) N = S-1; + + return ARRAY._latinSquare(S, N, false); + }; + + /** + * ## ARRAY.generateCombinations + * + * Generates all distinct combinations of exactly r elements each + * + * @param {array} array The array from which the combinations are extracted + * @param {number} r The number of elements in each combination + * + * @return {array} The total sets of combinations + * + * @see ARRAY.getGroupSizeN + * @see ARRAY.getNGroups + * @see ARRAY.matchN + * + * Kudos: http://rosettacode.org/wiki/Combinations#JavaScript + */ + ARRAY.generateCombinations = function combinations(arr, k) { + var i, subI, ret, sub, next; + ret = []; + for (i = 0; i < arr.length; i++) { + if (k === 1) { + ret.push( [ arr[i] ] ); + } + else { + sub = combinations(arr.slice(i+1, arr.length), k-1); + for (subI = 0; subI < sub.length; subI++ ){ + next = sub[subI]; + next.unshift(arr[i]); + ret.push( next ); + } + } + } + return ret; + }; + + /** + * ## ARRAY.matchN + * + * Match each element of the array with N random others + * + * If strict is equal to true, elements cannot be matched multiple times. + * + * *Important*: this method has a bug / feature. If the strict parameter + * is set, the last elements could remain without match, because all the + * other have been already used. Another recombination would be able + * to match all the elements instead. + * + * @param {array} array The array in which operate the matching + * @param {number} N The number of matches per element + * @param {boolean} strict Optional. If TRUE, matched elements cannot be + * repeated. Defaults, FALSE + * + * @return {array} The results of the matching + * + * @see ARRAY.getGroupSizeN + * @see ARRAY.getNGroups + * @see ARRAY.generateCombinations + */ + ARRAY.matchN = function(array, N, strict) { + var result, i, copy, group, len, found; + if (!array) return; + if (!N) return array; + + result = []; + len = array.length; + found = []; + for (i = 0 ; i < len ; i++) { + // Recreate the array. + copy = array.slice(0); + copy.splice(i,1); + if (strict) { + copy = ARRAY.arrayDiff(copy,found); + } + group = ARRAY.getNRandom(copy,N); + // Add to the set of used elements. + found = found.concat(group); + // Re-add the current element. + group.splice(0,0,array[i]); + result.push(group); + + // Update. + group = []; + } + return result; + }; + + /** + * ## ARRAY.rep + * + * Appends an array to itself a number of times and return a new array + * + * The original array is not modified. + * + * @param {array|mixed} array the array to repeat. If not an array, it + * it will be made an array. + * @param {number} times The number of times the array must be appended + * to itself + * + * @return {array} A copy of the original array appended to itself + */ + ARRAY.rep = function(array, times) { + var i, result; + if (!ARRAY.isArray(array)) array = [ array ]; + if (!times) return array.slice(0); + if (times < 1) { + JSUS.log('times must be greater or equal 1', 'ERR'); + return; + } + i = 1; + result = array.slice(0); + for (; i < times; i++) { + result = result.concat(array); + } + return result; + }; + + /** + * ## ARRAY.stretch + * + * Repeats each element of the array N times + * + * N can be specified as an integer or as an array. In the former case all + * the elements are repeat the same number of times. In the latter, each + * element can be repeated a custom number of times. If the length of the + * `times` array differs from that of the array to stretch a recycle rule + * is applied. + * + * The original array is not modified. + * + * E.g.: + * + * ```js + * var foo = [1,2,3]; + * + * ARRAY.stretch(foo, 2); // [1, 1, 2, 2, 3, 3] + * + * ARRAY.stretch(foo, [1,2,3]); // [1, 2, 2, 3, 3, 3]; + * + * ARRAY.stretch(foo, [2,1]); // [1, 1, 2, 3, 3]; + * ``` + * + * @param {array} array the array to strech + * @param {number|array} times The number of times each element + * must be repeated + * @return {array} A stretched copy of the original array + */ + ARRAY.stretch = function(array, times) { + var result, i, repeat, j; + if (!array) return; + if (!times) return array.slice(0); + if ('number' === typeof times) { + if (times < 1) { + JSUS.log('times must be greater or equal 1', 'ERR'); + return; + } + times = ARRAY.rep([times], array.length); + } + + result = []; + for (i = 0; i < array.length; i++) { + repeat = times[(i % times.length)]; + for (j = 0; j < repeat ; j++) { + result.push(array[i]); + } + } + return result; + }; + + + /** + * ## ARRAY.arrayIntersect + * + * Computes the intersection between two arrays + * + * Arrays can contain both primitive types and objects. + * + * @param {array} a1 The first array + * @param {array} a2 The second array + * @return {array} All the values of the first array that are found + * also in the second one + */ + ARRAY.arrayIntersect = function(a1, a2) { + return a1.filter( function(i) { + return JSUS.inArray(i, a2); + }); + }; + + /** + * ## ARRAY.arrayDiff + * + * Performs a diff between two arrays + * + * Arrays can contain both primitive types and objects. + * + * @param {array} a1 The first array + * @param {array} a2 The second array + * @return {array} All the values of the first array that are not + * found in the second one + */ + ARRAY.arrayDiff = function(a1, a2) { + return a1.filter( function(i) { + return !(JSUS.inArray(i, a2)); + }); + }; + + /** + * ## ARRAY.shuffle + * + * Shuffles the elements of the array using the Fischer algorithm + * + * The original array is not modified, and a copy is returned. + * + * @param {array} shuffle The array to shuffle + * + * @return {array} copy The shuffled array + * + * @see http://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle + */ + ARRAY.shuffle = function(array) { + var copy, len, j, tmp, i; + if (!array) return; + copy = Array.prototype.slice.call(array); + len = array.length-1; // ! -1 + for (i = len; i > 0; i--) { + j = Math.floor(Math.random()*(i+1)); + tmp = copy[j]; + copy[j] = copy[i]; + copy[i] = tmp; + } + return copy; + }; + + /** + * ## ARRAY.getNRandom + * + * Select N random elements from the array and returns them + * + * @param {array} array The array from which extracts random elements + * @paran {number} N The number of random elements to extract + * + * @return {array} An new array with N elements randomly chosen + */ + ARRAY.getNRandom = function(array, N) { + return ARRAY.shuffle(array).slice(0,N); + }; + + /** + * ## ARRAY.distinct + * + * Removes all duplicates entries from an array and returns a copy of it + * + * Does not modify original array. + * + * Comparison is done with `JSUS.equals`. + * + * @param {array} array The array from which eliminates duplicates + * + * @return {array} A copy of the array without duplicates + * + * @see JSUS.equals + */ + ARRAY.distinct = function(array) { + var out = []; + if (!array) return out; + + ARRAY.each(array, function(e) { + if (!ARRAY.inArray(e, out)) { + out.push(e); + } + }); + return out; + }; + + /** + * ## ARRAY.transpose + * + * Transposes a given 2D array. + * + * The original array is not modified, and a new copy is + * returned. + * + * @param {array} array The array to transpose + * + * @return {array} The Transposed Array + */ + ARRAY.transpose = function(array) { + if (!array) return; + + // Calculate width and height + var w, h, i, j, t = []; + w = array.length || 0; + h = (ARRAY.isArray(array[0])) ? array[0].length : 0; + if (w === 0 || h === 0) return t; + + for ( i = 0; i < h; i++) { + t[i] = []; + for ( j = 0; j < w; j++) { + t[i][j] = array[j][i]; + } + } + return t; + }; + + JSUS.extend(ARRAY); + +})('undefined' !== typeof JSUS ? JSUS : module.parent.exports.JSUS); + +/** + * # DOM + * Copyright(c) 2019 Stefano Balietti + * MIT Licensed + * + * Helper library to perform generic operation with DOM elements. + */ +(function(JSUS) { + + "use strict"; + + var onFocusChange, changeTitle; + + function DOM() {} + + // ## GET/ADD + + /** + * ### DOM.get + * + * Creates a generic HTML element with specified attributes + * + * @param {string} elem The name of the tag + * @param {object|string} attributes Optional. Object containing + * attributes for the element. If string, the id of the element. If + * the request element is an 'iframe', the `name` attribute is set + * equal to the `id` attribute. + * + * @return {HTMLElement} The newly created HTML element + * + * @see DOM.add + * @see DOM.addAttributes + */ + DOM.get = function(name, attributes) { + var el; + el = document.createElement(name); + if ('string' === typeof attributes) el.id = attributes; + else if (attributes) this.addAttributes(el, attributes); + // For firefox, name of iframe must be set as well. + if (name === 'iframe' && el.id && !el.name) el.name = el.id; + return el; + }; + + /** + * ### DOM.add|append + * + * Creates and append an element with specified attributes to a root + * + * @param {string} name The name of the HTML tag + * @param {HTMLElement} root The root element to which the new element + * will be appended + * @param {object|string} options Optional. Object containing + * attributes for the element and rules about how to insert it relative + * to root. Available options: insertAfter, insertBefore (default: + * child of root). If string, it is the id of the element. Examples: + * + * ```javascript + * // Appends a new new to the body. + * var div = DOM.add('div', document.body); + * // Appends a new new to the body with id 'myid'. + * var div1 = DOM.add('div', document.body, 'myid'); + * // Appends a new new to the body with id 'myid2' and class name 'c'. + * var div2 = DOM.add('div', document.body, { id: 'myid2', className: 'c'}); + * // Appends a new div after div1 with id 'myid'. + * var div3 = DOM.add('div', div1, { id: 'myid3', insertAfter: true }); + * // Appends a new div before div2 with id 'myid'. + * var div3 = DOM.add('div', div2, { id: 'myid3', insertBefore: true }); + * ``` + * + * @return {HTMLElement} The newly created HTML element + * + * @see DOM.get + * @see DOM.addAttributes + */ + DOM.add = DOM.append = function(name, root, options) { + var el; + el = this.get(name, options); + if (options && options.insertBefore) { + if (options.insertAfter) { + throw new Error('DOM.add: options.insertBefore and ' + + 'options.insertBefore cannot be ' + + 'both set.'); + } + if (!root.parentNode) { + throw new Error('DOM.add: root.parentNode not found. ' + + 'Cannot insert before.'); + } + root.parentNode.insertBefore(el, root); + } + else if (options && options.insertAfter) { + if (!root.parentNode) { + throw new Error('DOM.add: root.parentNode not found. ' + + 'Cannot insert after.'); + } + DOM.insertAfter(el, root); + } + else { + root.appendChild(el); + } + return el; + }; + + /** + * ### DOM.addAttributes + * + * Adds attributes to an HTML element and returns it + * + * Attributes are defined as key-values pairs and added + * + * Special cases: + * + * - 'className': alias for class + * - 'class': add a class to the className property (does not overwrite) + * - 'style': adds property to the style property (see DOM.style) + * - 'id': the id of the element + * - 'innerHTML': the innerHTML property of the element (overwrites) + * - 'insertBefore': ignored + * - 'insertAfter': ignored + * + * @param {HTMLElement} elem The element to decorate + * @param {object} attributes Object containing attributes to + * add to the element + * + * @return {HTMLElement} The element with speficied attributes added + * + * @see DOM.addClass + * @see DOM.style + */ + DOM.addAttributes = function(elem, attributes) { + var key; + if (!DOM.isElement(elem)) { + throw new TypeError('DOM.addAttributes: elem must be ' + + 'HTMLElement. Found: ' + elem); + } + if ('undefined' === typeof attributes) return elem; + if ('object' !== typeof attributes) { + throw new TypeError('DOM.addAttributes: attributes must be ' + + 'object or undefined. Found: ' + attributes); + } + for (key in attributes) { + if (attributes.hasOwnProperty(key)) { + if (key === 'id' || key === 'innerHTML') { + elem[key] = attributes[key]; + } + else if (key === 'class' || key === 'className') { + DOM.addClass(elem, attributes[key]); + } + else if (key === 'style') { + DOM.style(elem, attributes[key]); + } + else if (key !== 'insertBefore' && key !== 'insertAfter') { + elem.setAttribute(key, attributes[key]); + } + } + } + return elem; + }; + + // ## WRITE + + /** + * ### DOM.write + * + * Write a text, or append an HTML element or node, into a root element + * + * @param {HTMLElement} root The HTML element where to write into + * @param {string|HTMLElement} text The text to write or an element + * to append. Default: an ampty string + * + * @return {TextNode} The text node inserted in the root element + * + * @see DOM.writeln + */ + DOM.write = function(root, text) { + var content; + if ('undefined' === typeof text || text === null) text = ""; + if (JSUS.isNode(text) || JSUS.isElement(text)) content = text; + else content = document.createTextNode(text); + root.appendChild(content); + return content; + }; + + /** + * ### DOM.writeln + * + * Write a text and a break into a root element + * + * Default break element is
tag + * + * @param {HTMLElement} root The HTML element where to write into + * @param {string|HTMLElement} text The text to write or an element + * to append. Default: an ampty string + * @param {string} rc the name of the tag to use as a break element + * + * @return {TextNode} The text node inserted in the root element + * + * @see DOM.write + */ + DOM.writeln = function(root, text, rc) { + var content; + content = DOM.write(root, text); + this.add(rc || 'br', root); + return content; + }; + + /** + * ### DOM.sprintf + * + * Builds up a decorated HTML text element + * + * Performs string substitution from an args object where the first + * character of the key bears the following semantic: + * + * - '@': variable substitution with escaping + * - '!': variable substitution without variable escaping + * - '%': wraps a portion of string into a _span_ element to which is + * possible to associate a css class or id. Alternatively, + * it also possible to add in-line style. E.g.: + * + * ```javascript + * sprintf('%sImportant!%s An error has occurred: %pre@err%pre', { + * '%pre': { + * style: 'font-size: 12px; font-family: courier;' + * }, + * '%s': { + * id: 'myId', + * 'class': 'myClass', + * }, + * '@err': 'file not found', + * }, document.body); + * ``` + * + * Special span elements are %strong and %em, which add + * respectively a _strong_ and _em_ tag instead of the default + * _span_ tag. They cannot be styled. + * + * @param {string} string A text to transform + * @param {object} args Optional. An object containing string + * transformations + * @param {Element} root Optional. An HTML element to which append the + * string. Defaults, a new _span_ element + * + * @return {Element} The root element. + */ + DOM.sprintf = function(string, args, root) { + + var text, span, idx_start, idx_finish, idx_replace, idxs; + var spans, key, i; + + root = root || document.createElement('span'); + spans = {}; + + // Create an args object, if none is provided. + // Defaults %em and %strong are added. + args = args || {}; + args['%strong'] = ''; + args['%em'] = ''; + + // Transform arguments before inserting them. + for (key in args) { + if (args.hasOwnProperty(key)) { + + switch(key.charAt(0)) { + + case '%': // Span/Strong/Emph . + + idx_start = string.indexOf(key); + + // Pattern not found. No error. + if (idx_start === -1) continue; + + idx_replace = idx_start + key.length; + idx_finish = string.indexOf(key, idx_replace); + + if (idx_finish === -1) { + JSUS.log('Error. Could not find closing key: ' + key); + continue; + } + + // Can be strong, emph or a generic span. + spans[idx_start] = key; + + break; + + case '@': // Replace and sanitize. + string = string.replace(key, escape(args[key])); + break; + + case '!': // Replace and not sanitize. + string = string.replace(key, args[key]); + break; + + default: + JSUS.log('Identifier not in [!,@,%]: ' + key[0]); + + } + } + } + + // No span to create, return what we have. + if (!JSUS.size(spans)) { + return root.appendChild(document.createTextNode(string)); + } + + // Re-assamble the string. + + idxs = JSUS.keys(spans).sort(function(a, b){ return a - b; }); + idx_finish = 0; + for (i = 0; i < idxs.length; i++) { + + // Add span. + key = spans[idxs[i]]; + idx_start = string.indexOf(key); + + // Add fragments of string. + if (idx_finish !== idx_start-1) { + root.appendChild(document.createTextNode( + string.substring(idx_finish, idx_start))); + } + + idx_replace = idx_start + key.length; + idx_finish = string.indexOf(key, idx_replace); + + if (key === '%strong') { + span = document.createElement('strong'); + } + else if (key === '%em') { + span = document.createElement('em'); + } + else { + span = DOM.get('span', args[key]); + } + + text = string.substring(idx_replace, idx_finish); + + span.appendChild(document.createTextNode(text)); + + root.appendChild(span); + idx_finish = idx_finish + key.length; + } + + // Add the final part of the string. + if (idx_finish !== string.length) { + root.appendChild(document.createTextNode( + string.substring(idx_finish))); + } + + return root; + }; + + // ## ELEMENTS + + /** + * ### DOM.isNode + * + * Returns TRUE if the object is a DOM node + * + * @param {mixed} The variable to check + * + * @return {boolean} TRUE, if the the object is a DOM node + */ + DOM.isNode = function(o) { + if (!o || 'object' !== typeof o) return false; + return 'object' === typeof Node ? o instanceof Node : + 'number' === typeof o.nodeType && + 'string' === typeof o.nodeName; + }; + + /** + * ### DOM.isElement + * + * Returns TRUE if the object is a DOM element + * + * Notice: instanceof HTMLElement is not reliable in Safari, even if + * the method is defined. + * + * @param {mixed} The variable to check + * + * @return {boolean} TRUE, if the the object is a DOM element + */ + DOM.isElement = function(o) { + return o && 'object' === typeof o && o.nodeType === 1 && + 'string' === typeof o.nodeName; + }; + + /** + * ### DOM.shuffleElements + * + * Shuffles the order of children of a parent Element + * + * All children *must* have the id attribute (live list elements cannot + * be identified by position). + * + * Notice the difference between Elements and Nodes: + * + * http://stackoverflow.com/questions/7935689/ + * what-is-the-difference-between-children-and-childnodes-in-javascript + * + * @param {Node} parent The parent node + * @param {array} order Optional. A pre-specified order. Defaults, random + * @param {function} cb Optional. A callback to execute one each shuffled + * element (after re-positioning). This is always the last parameter, + * so if order is omitted, it goes second. The callback takes as input: + * - the element + * - the new order + * - the old order + * + * + * @return {array} The order used to shuffle the nodes + */ + DOM.shuffleElements = function(parent, order, cb) { + var i, len, numOrder, idOrder, children, child; + var id; + if (!JSUS.isNode(parent)) { + throw new TypeError('DOM.shuffleElements: parent must be a node. ' + + 'Found: ' + parent); + } + if (!parent.children || !parent.children.length) { + JSUS.log('DOM.shuffleElements: parent has no children.', 'ERR'); + return false; + } + if (order) { + if ('undefined' === typeof cb && 'function' === typeof order) { + cb = order; + } + else { + if (!JSUS.isArray(order)) { + throw new TypeError('DOM.shuffleElements: order must be ' + + 'array. Found: ' + order); + } + if (order.length !== parent.children.length) { + throw new Error('DOM.shuffleElements: order length must ' + + 'match the number of children nodes.'); + } + } + } + if (cb && 'function' !== typeof cb) { + throw new TypeError('DOM.shuffleElements: order must be ' + + 'array. Found: ' + order); + } + + // DOM4 compliant browsers. + children = parent.children; + + //https://developer.mozilla.org/en/DOM/Element.children + //[IE lt 9] IE < 9 + if ('undefined' === typeof children) { + child = this.firstChild; + while (child) { + if (child.nodeType == 1) children.push(child); + child = child.nextSibling; + } + } + + // Get all ids. + len = children.length; + idOrder = new Array(len); + if (cb) numOrder = new Array(len); + if (!order) order = JSUS.sample(0, (len-1)); + for (i = 0 ; i < len; i++) { + id = children[order[i]].id; + if ('string' !== typeof id || id === "") { + throw new Error('DOM.shuffleElements: no id found on ' + + 'child n. ' + order[i]); + } + idOrder[i] = id; + if (cb) numOrder[i] = order[i]; + } + + // Two fors are necessary to follow the real sequence (Live List). + // However, parent.children is a special object, so the sequence + // could be unreliable. + for (i = 0 ; i < len; i++) { + parent.appendChild(children[idOrder[i]]); + if (cb) cb(children[idOrder[i]], i, numOrder[i]); + } + return idOrder; + }; + + /** + * ### DOM.populateSelect + * + * Appends a list of options into a HTML select element + * + * @param {HTMLElement} select HTML select element + * @param {object} options Optional. List of options to add to + * the select element. List is in the format of key-values pairs + * as innerHTML and value attributes of the option. + * + * @return {HTMLElement} select The updated select element + */ + DOM.populateSelect = function(select, options) { + var key, opt; + if (!DOM.isElement(select)) { + throw new TypeError('DOM.populateSelect: select must be ' + + 'HTMLElement. Found: ' + select); + } + if (options) { + if ('object' !== typeof options) { + throw new TypeError('DOM.populateSelect: options must be ' + + 'object or undefined. Found: ' + options); + } + for (key in options) { + if (options.hasOwnProperty(key)) { + opt = document.createElement('option'); + opt.value = key; + opt.innerHTML = options[key]; + select.appendChild(opt); + } + } + } + return select; + }; + + /** + * ### DOM.removeChildrenFromNode + * + * Removes all children from a node + * + * @param {HTMLNode} node HTML node. + */ + DOM.removeChildrenFromNode = function(node) { + while (node.hasChildNodes()) { + node.removeChild(node.firstChild); + } + }; + + /** + * ### DOM.insertAfter + * + * Inserts a node element after another one + * + * @param {Node} node The node element to insert + * @param {Node} referenceNode The node element after which the + * the insertion is performed + * + * @return {Node} The inserted node + */ + DOM.insertAfter = function(node, referenceNode) { + return referenceNode.insertBefore(node, referenceNode.nextSibling); + }; + + // ## CSS / JS + + /** + * ### DOM.addCSS + * + * Adds a CSS link to the page + * + * @param {string} cssPath The path to the css + * @param {HTMLElement} root Optional. The root element. If no root + * element is passed, it tries document.head, document.body, and + * document. If it fails, it throws an error. + * @param {object|string} attributes Optional. Object containing + * attributes for the element. If string, the id of the element + * + * @return {HTMLElement} The link element + */ + DOM.addCSS = function(cssPath, root, attributes) { + if ('string' !== typeof cssPath || cssPath.trim() === '') { + throw new TypeError('DOM.addCSS: cssPath must be a non-empty ' + + 'string. Found: ' + cssPath); + } + root = root || document.head || document.body || document; + if (!root) { + throw new Error('DOM.addCSS: root is undefined, and could not ' + + 'detect a valid root for css: ' + cssPath); + } + attributes = JSUS.mixin({ + rel : 'stylesheet', + type: 'text/css', + href: cssPath + }, attributes); + return this.add('link', root, attributes); + }; + + /** + * ### DOM.addJS + * + * Adds a JavaScript script to the page + * + * @param {string} cssPath The path to the css + * @param {HTMLElement} root Optional. The root element. If no root + * element is passed, it tries document.head, document.body, and + * document. If it fails, it throws an error. + * @param {object|string} attributes Optional. Object containing + * attributes for the element. If string, the id of the element + * + * @return {HTMLElement} The link element + * + */ + DOM.addJS = function(jsPath, root, attributes) { + if ('string' !== typeof jsPath || jsPath.trim() === '') { + throw new TypeError('DOM.addCSS: jsPath must be a non-empty ' + + 'string. Found: ' + jsPath); + } + root = root || document.head || document.body || document; + if (!root) { + throw new Error('DOM.addCSS: root is undefined, and could not ' + + 'detect a valid root for css: ' + jsPath); + } + attributes = JSUS.mixin({ + charset : 'utf-8', + type: 'text/javascript', + src: jsPath + }, attributes); + return this.add('script', root, attributes); + }; + + // ## STYLE + + /** + * ### DOM.highlight + * + * Highlights an element by adding a custom border around it + * + * Three pre-defined modes are implemented: + * + * - OK: green + * - WARN: yellow + * - ERR: red (default) + * + * Alternatively, it is possible to specify a custom + * color as HEX value. Examples: + * + * ```javascript + * highlight(myDiv, 'WARN'); // yellow border + * highlight(myDiv); // red border + * highlight(myDiv, '#CCC'); // grey border + * ``` + * + * @param {HTMLElement} elem The element to highlight + * @param {string} code The type of highlight + * + * @return {HTMLElement} elem The styled element + * + * @see DOM.addBorder + * @see DOM.style + */ + DOM.highlight = function(elem, code) { + var color; + // Default value is ERR. + switch (code) { + case 'OK': + color = 'green'; + break; + case 'WARN': + color = 'yellow'; + break; + case 'ERR': + color = 'red'; + break; + default: + if (code.charAt(0) === '#') color = code; + else color = 'red'; + } + return this.addBorder(elem, color); + }; + + /** + * ### DOM.addBorder + * + * Adds a border around the specified element + * + * @param {HTMLElement} elem The element to which adding the borders + * @param {string} color Optional. The color of border. Default: 'red'. + * @param {string} width Optional. The width of border. Default: '5px'. + * @param {string} type Optional. The type of border. Default: 'solid'. + * + * @return {HTMLElement} The element to which a border has been added + */ + DOM.addBorder = function(elem, color, width, type) { + var properties; + color = color || 'red'; + width = width || '5px'; + type = type || 'solid'; + properties = { border: width + ' ' + type + ' ' + color }; + return DOM.style(elem, properties); + }; + + /** + * ### DOM.style + * + * Styles an element as an in-line css. + * + * Existing style properties are maintained, and new ones added. + * + * @param {HTMLElement} elem The element to style + * @param {object} Objects containing the properties to add. + * + * @return {HTMLElement} The styled element + */ + DOM.style = function(elem, properties) { + var i; + if (!DOM.isElement(elem)) { + throw new TypeError('DOM.style: elem must be HTMLElement. ' + + 'Found: ' + elem); + } + if (properties) { + if ('object' !== typeof properties) { + throw new TypeError('DOM.style: properties must be object or ' + + 'undefined. Found: ' + properties); + } + for (i in properties) { + if (properties.hasOwnProperty(i)) { + elem.style[i] = properties[i]; + } + } + } + return elem; + }; + + // ## ID + + /** + * ### DOM.generateUniqueId + * + * Generates a unique id for the whole page, frames included + * + * The resulting id is of the type: prefix_randomdigits. + * + * @param {string} prefix Optional. A given prefix. Default: a random + * string of 8 characters. + * @param {boolean} checkFrames Optional. If TRUE, the id will be unique + * all frames as well. Default: TRUE + * + * @return {string} id The unique id + */ + DOM.generateUniqueId = (function() { + var limit; + limit = 100; + + // Returns TRUE if id is NOT found in all docs (optimized). + function scanDocuments(docs, id) { + var i, len; + len = docs.length; + if (len === 1) { + return !docs[0].document.getElementById(id); + } + if (len === 2) { + return !!(docs[0].document.getElementById(id) && + docs[1].document.getElementById(id)); + } + i = -1; + for ( ; ++i < len ; ) { + if (docs[i].document.getElementById(id)) return false; + } + return true; + } + + return function(prefix, checkFrames) { + var id, windows; + var found, counter; + + if (prefix) { + if ('string' !== typeof prefix && 'number' !== typeof prefix) { + throw new TypeError('DOM.generateUniqueId: prefix must ' + + 'be string or number. Found: ' + + prefix); + } + } + else { + prefix = JSUS.randomString(8, 'a'); + } + id = prefix + '_'; + + windows = [ window ]; + if ((checkFrames || 'undefined' === typeof checkFrames) && + window.frames) { + + windows = windows.concat(window.frames); + } + + found = true; + counter = -1; + while (found) { + id = prefix + '_' + JSUS.randomInt(1000); + found = scanDocuments(windows, id); + if (++counter > limit) { + throw new Error('DOM.generateUniqueId: could not ' + + 'find unique id within ' + limit + + ' trials.'); + } + } + return id; + }; + })(); + + // ## CLASSES + + /** + * ### DOM.removeClass + * + * Removes a specific class from the className attribute of a given element + * + * @param {HTMLElement|object} elem An HTML element, or an object with + * a className property if force is TRUE + * @param {string} className The name of a CSS class already in the element + * @param {boolean} force Optional. If TRUE, the method is applied also + * to non HTMLElements + * + * @return {HTMLElement|undefined} The HTML element with the removed + * class, or undefined if the inputs are misspecified + */ + DOM.removeClass = function(elem, className, force) { + var regexpr; + if (!force && !DOM.isElement(elem)) { + throw new TypeError('DOM.removeClass: elem must be HTMLElement. ' + + 'Found: ' + elem); + } + if (className) { + if ('string' !== typeof className || className.trim() === '') { + throw new TypeError('DOM.removeClass: className must be ' + + 'HTMLElement. Found: ' + className); + } + regexpr = new RegExp('(?:^|\\s)' + className + '(?!\\S)'); + elem.className = elem.className.replace(regexpr, '' ); + } + return elem; + }; + + /** + * ### DOM.addClass + * + * Adds one or more classes to the className attribute of the given element + * + * Takes care not to overwrite already existing classes. + * + * @param {HTMLElement|object} elem An HTML element, or an object with + * a className property if force is TRUE + * @param {string|array} className The name/s of CSS class/es + * @param {boolean} force Optional. If TRUE, the method is applied also + * to non HTMLElements + * + * @return {HTMLElement} The HTML element with the additional + * class, or undefined if the inputs are misspecified + */ + DOM.addClass = function(elem, className, force) { + if (!force && !DOM.isElement(elem)) { + throw new TypeError('DOM.addClass: elem must be HTMLElement. ' + + 'Found: ' + elem); + } + if (className) { + if (className instanceof Array) className = className.join(' '); + if ('string' !== typeof className || className.trim() === '') { + throw new TypeError('DOM.addClass: className must be ' + + 'HTMLElement. Found: ' + className); + } + if (!elem.className) elem.className = className; + else elem.className += (' ' + className); + } + return elem; + }; + + /** + * ### DOM.getElementsByClassName + * + * Returns an array of elements with requested class name + * + * @param {object} document The document object of a window or iframe + * @param {string} className The requested className + * @param {string} nodeName Optional. If set only elements with + * the specified tag name will be searched + * + * @return {array} Array of elements with the requested class name + * + * @see https://gist.github.com/E01T/6088383 + * @see http://stackoverflow.com/ + * questions/8808921/selecting-a-css-class-with-xpath + */ + DOM.getElementsByClassName = function(document, className, nodeName) { + var result, node, tag, seek, i, rightClass; + result = []; + tag = nodeName || '*'; + if (document.evaluate) { + seek = '//' + tag + + '[contains(concat(" ", normalize-space(@class), " "), "' + + className + ' ")]'; + seek = document.evaluate(seek, document, null, 0, null ); + while ((node = seek.iterateNext())) { + result.push(node); + } + } + else { + rightClass = new RegExp( '(^| )'+ className +'( |$)' ); + seek = document.getElementsByTagName(tag); + for (i = 0; i < seek.length; i++) + if (rightClass.test((node = seek[i]).className )) { + result.push(seek[i]); + } + } + return result; + }; + + // ## IFRAME + + /** + * ### DOM.getIFrameDocument + * + * Returns a reference to the document of an iframe object + * + * @param {HTMLIFrameElement} iframe The iframe object + * + * @return {HTMLDocument|null} The document of the iframe, or + * null if not found. + */ + DOM.getIFrameDocument = function(iframe) { + if (!iframe) return null; + return iframe.contentDocument || + iframe.contentWindow ? iframe.contentWindow.document : null; + }; + + /** + * ### DOM.getIFrameAnyChild + * + * Gets the first available child of an IFrame + * + * Tries head, body, lastChild and the HTML element + * + * @param {HTMLIFrameElement} iframe The iframe object + * + * @return {HTMLElement|undefined} The child, or undefined if none is found + */ + DOM.getIFrameAnyChild = function(iframe) { + var contentDocument; + if (!iframe) return; + contentDocument = DOM.getIFrameDocument(iframe); + return contentDocument.head || contentDocument.body || + contentDocument.lastChild || + contentDocument.getElementsByTagName('html')[0]; + }; + + // ## EVENTS + + /** + * ### DOM.addEvent + * + * Adds an event listener to an element (cross-browser) + * + * @param {Element} element A target element + * @param {string} event The name of the event to handle + * @param {function} func The event listener + * @param {boolean} Optional. If TRUE, the event will initiate a capture. + * Available only in some browsers. Default, FALSE + * + * @return {boolean} TRUE, on success. However, the return value is + * browser dependent. + * + * @see DOM.removeEvent + * + * Kudos: + * http://stackoverflow.com/questions/6348494/addeventlistener-vs-onclick + */ + DOM.addEvent = function(element, event, func, capture) { + capture = !!capture; + if (element.attachEvent) return element.attachEvent('on' + event, func); + else return element.addEventListener(event, func, capture); + }; + + /** + * ### DOM.removeEvent + * + * Removes an event listener from an element (cross-browser) + * + * @param {Element} element A target element + * @param {string} event The name of the event to remove + * @param {function} func The event listener + * @param {boolean} Optional. If TRUE, the event was registered + * as a capture. Available only in some browsers. Default, FALSE + * + * @return {boolean} TRUE, on success. However, the return value is + * browser dependent. + * + * @see DOM.addEvent + */ + DOM.removeEvent = function(element, event, func, capture) { + capture = !!capture; + if (element.detachEvent) return element.detachEvent('on' + event, func); + else return element.removeEventListener(event, func, capture); + }; + + /** + * ### DOM.onFocusIn + * + * Registers a callback to be executed when the page acquires focus + * + * @param {function|null} cb Callback executed if page acquires focus, + * or NULL, to delete an existing callback. + * @param {object|function} ctx Optional. Context of execution for cb + * + * @see onFocusChange + */ + DOM.onFocusIn = function(cb, ctx) { + var origCb; + if ('function' !== typeof cb && null !== cb) { + throw new TypeError('JSUS.onFocusIn: cb must be function or null.'); + } + if (ctx) { + if ('object' !== typeof ctx && 'function' !== typeof ctx) { + throw new TypeError('JSUS.onFocusIn: ctx must be object, ' + + 'function or undefined.'); + } + origCb = cb; + cb = function() { origCb.call(ctx); }; + } + + onFocusChange(cb); + }; + + /** + * ### DOM.onFocusOut + * + * Registers a callback to be executed when the page loses focus + * + * @param {function} cb Callback executed if page loses focus, + * or NULL, to delete an existing callback. + * @param {object|function} ctx Optional. Context of execution for cb + * + * @see onFocusChange + */ + DOM.onFocusOut = function(cb, ctx) { + var origCb; + if ('function' !== typeof cb && null !== cb) { + throw new TypeError('JSUS.onFocusOut: cb must be ' + + 'function or null.'); + } + if (ctx) { + if ('object' !== typeof ctx && 'function' !== typeof ctx) { + throw new TypeError('JSUS.onFocusIn: ctx must be object, ' + + 'function or undefined.'); + } + origCb = cb; + cb = function() { origCb.call(ctx); }; + } + onFocusChange(undefined, cb); + }; + + // ## UI + + /** + * ### DOM.disableRightClick + * + * Disables the popup of the context menu by right clicking with the mouse + * + * @param {Document} Optional. A target document object. Defaults, document + * + * @see DOM.enableRightClick + */ + DOM.disableRightClick = function(doc) { + doc = doc || document; + if (doc.layers) { + doc.captureEvents(Event.MOUSEDOWN); + doc.onmousedown = function clickNS4(e) { + if (doc.layers || doc.getElementById && !doc.all) { + if (e.which == 2 || e.which == 3) { + return false; + } + } + }; + } + else if (doc.all && !doc.getElementById) { + doc.onmousedown = function clickIE4() { + if (event.button == 2) { + return false; + } + }; + } + doc.oncontextmenu = function() { return false; }; + }; + + /** + * ### DOM.enableRightClick + * + * Enables the popup of the context menu by right clicking with the mouse + * + * It unregisters the event handlers created by `DOM.disableRightClick` + * + * @param {Document} Optional. A target document object. Defaults, document + * + * @see DOM.disableRightClick + */ + DOM.enableRightClick = function(doc) { + doc = doc || document; + if (doc.layers) { + doc.releaseEvents(Event.MOUSEDOWN); + doc.onmousedown = null; + } + else if (doc.all && !doc.getElementById) { + doc.onmousedown = null; + } + doc.oncontextmenu = null; + }; + + /** + * ### DOM.disableBackButton + * + * Disables/re-enables backward navigation in history of browsed pages + * + * When disabling, it inserts twice the current url. + * + * It will still be possible to manually select the uri in the + * history pane and nagivate to it. + * + * @param {boolean} disable Optional. If TRUE disables back button, + * if FALSE, re-enables it. Default: TRUE. + * + * @return {boolean} The state of the back button (TRUE = disabled), + * or NULL if the method is not supported by browser. + */ + DOM.disableBackButton = (function(isDisabled) { + return function(disable) { + disable = 'undefined' === typeof disable ? true : disable; + if (disable && !isDisabled) { + if (!history.pushState || !history.go) { + JSUS.log('DOM.disableBackButton: method not ' + + 'supported by browser.'); + return null; + } + history.pushState(null, null, location.href); + window.onpopstate = function(event) { + history.go(1); + }; + } + else if (isDisabled) { + window.onpopstate = null; + } + isDisabled = disable; + return disable; + }; + })(false); + + // ## EXTRA + + /** + * ### DOM.playSound + * + * Plays a sound + * + * @param {various} sound Audio tag or path to audio file to be played + */ + DOM.playSound = 'undefined' === typeof Audio ? + function() { + console.log('JSUS.playSound: Audio tag not supported in your' + + ' browser. Cannot play sound.'); + } : + function(sound) { + var audio; + if ('string' === typeof sound) { + audio = new Audio(sound); + } + else if ('object' === typeof sound && + 'function' === typeof sound.play) { + audio = sound; + } + else { + throw new TypeError('JSUS.playSound: sound must be string' + + ' or audio element.'); + } + audio.play(); + }; + + /** + * ### DOM.blinkTitle + * + * Changes the title of the page in regular intervals + * + * Calling the function without any arguments stops the blinking + * If an array of strings is provided, that array will be cycled through. + * If a signle string is provided, the title will alternate between '!!!' + * and that string. + * + * @param {mixed} titles New title to blink + * @param {object} options Optional. Configuration object. + * Accepted values and default in parenthesis: + * + * - stopOnFocus (false): Stop blinking if user switched to tab + * - stopOnClick (false): Stop blinking if user clicks on the + * specified element + * - finalTitle (document.title): Title to set after blinking is done + * - repeatFor (undefined): Show each element in titles at most + * N times -- might be stopped earlier by other events. + * - startOnBlur(false): Start blinking if user switches + * away from tab + * - period (1000) How much time between two blinking texts in the title + * + * @return {function|null} A function to clear the blinking of texts, + * or NULL, if the interval was not created yet (e.g. with startOnBlur + * option), or just destroyed. + */ + DOM.blinkTitle = (function(id) { + var clearBlinkInterval, finalTitle, elem; + clearBlinkInterval = function() { + clearInterval(id); + id = null; + if (elem) { + elem.removeEventListener('click', clearBlinkInterval); + elem = null; + } + if (finalTitle) { + document.title = finalTitle; + finalTitle = null; + } + }; + return function(titles, options) { + var period, where, rotation; + var rotationId, nRepeats; + + if (null !== id) clearBlinkInterval(); + if ('undefined' === typeof titles) return null; + + where = 'JSUS.blinkTitle: '; + options = options || {}; + + // Option finalTitle. + if ('undefined' === typeof options.finalTitle) { + finalTitle = document.title; + } + else if ('string' === typeof options.finalTitle) { + finalTitle = options.finalTitle; + } + else { + throw new TypeError(where + 'options.finalTitle must be ' + + 'string or undefined. Found: ' + + options.finalTitle); + } + + // Option repeatFor. + if ('undefined' !== typeof options.repeatFor) { + nRepeats = JSUS.isInt(options.repeatFor, 0); + if (false === nRepeats) { + throw new TypeError(where + 'options.repeatFor must be ' + + 'a positive integer. Found: ' + + options.repeatFor); + } + } + + // Option stopOnFocus. + if (options.stopOnFocus) { + JSUS.onFocusIn(function() { + clearBlinkInterval(); + onFocusChange(null, null); + }); + } + + // Option stopOnClick. + if ('undefined' !== typeof options.stopOnClick) { + if ('object' !== typeof options.stopOnClick || + !options.stopOnClick.addEventListener) { + + throw new TypeError(where + 'options.stopOnClick must be ' + + 'an HTML element with method ' + + 'addEventListener. Found: ' + + options.stopOnClick); + } + elem = options.stopOnClick; + elem.addEventListener('click', clearBlinkInterval); + } + + // Option startOnBlur. + if (options.startOnBlur) { + options.startOnBlur = null; + JSUS.onFocusOut(function() { + JSUS.blinkTitle(titles, options); + }); + return null; + } + + // Prepare the rotation. + if ('string' === typeof titles) { + titles = [titles, '!!!']; + } + else if (!JSUS.isArray(titles)) { + throw new TypeError(where + 'titles must be string, ' + + 'array of strings or undefined. Found: ' + + titles); + } + rotationId = 0; + period = options.period || 1000; + // Function to be executed every period. + rotation = function() { + changeTitle(titles[rotationId]); + rotationId = (rotationId+1) % titles.length; + // Control the number of times it should be cycled through. + if ('number' === typeof nRepeats) { + if (rotationId === 0) { + nRepeats--; + if (nRepeats === 0) clearBlinkInterval(); + } + } + }; + // Perform first rotation right now. + rotation(); + id = setInterval(rotation, period); + + // Return clear function. + return clearBlinkInterval; + }; + })(null); + + /** + * ### DOM.cookieSupport + * + * Tests for cookie support + * + * @return {boolean|null} The type of support for cookies. Values: + * + * - null: no cookies + * - false: only session cookies + * - true: session cookies and persistent cookies (although + * the browser might clear them on exit) + * + * Kudos: http://stackoverflow.com/questions/2167310/ + * how-to-show-a-message-only-if-cookies-are-disabled-in-browser + */ + DOM.cookieSupport = function() { + var c, persist; + persist = true; + do { + c = 'gCStest=' + Math.floor(Math.random()*100000000); + document.cookie = persist ? c + + ';expires=Tue, 01-Jan-2030 00:00:00 GMT' : c; + + if (document.cookie.indexOf(c) !== -1) { + document.cookie= c + ';expires=Sat, 01-Jan-2000 00:00:00 GMT'; + return persist; + } + } while (!(persist = !persist)); + + return null; + }; + + /** + * ### DOM.viewportSize + * + * Returns the current size of the viewport in pixels + * + * The viewport's size is the actual visible part of the browser's + * window. This excludes, for example, the area occupied by the + * JavaScript console. + * + * @param {string} dim Optional. Controls the return value ('x', or 'y') + * + * @return {object|number} An object containing x and y property, or + * number specifying the value for x or y + * + * Kudos: http://stackoverflow.com/questions/3437786/ + * get-the-size-of-the-screen-current-web-page-and-browser-window + */ + DOM.viewportSize = function(dim) { + var w, d, e, g, x, y; + if (dim && dim !== 'x' && dim !== 'y') { + throw new TypeError('DOM.viewportSize: dim must be "x","y" or ' + + 'undefined. Found: ' + dim); + } + w = window; + d = document; + e = d.documentElement; + g = d.getElementsByTagName('body')[0]; + x = w.innerWidth || e.clientWidth || g.clientWidth; + y = w.innerHeight|| e.clientHeight|| g.clientHeight; + return !dim ? { x: x, y: y } : dim === 'x' ? x : y; + }; + + /** + * ### DOM.makeTabbable + * + * Adds the tabindex property to an HTML element + * + * @param {HTMLElement} elem The element to make tabbable + * @param {object} opts Optional. Configuration options, avalable: + * - index: the tabindex, default 0 + * - clickable: if TRUE, calls DOM.makeClickable on the element. + * Default: FALSE + * + * @return {HTMLElement} The tabbable element + */ + DOM.makeTabbable = function(elem, opts) { + opts = opts || {}; + elem.setAttribute('tabindex', opts.index || 0); + if (opts.clicklable) DOM.makeClickable(elem); + return elem; + }; + + /** + * ### DOM.makeClickable + * + * Adds a listener that clicks on the element if SPACE or ENTER are hit + * + * The kewydown event listener callback is available under `cb`. + * + * @param {HTMLElement} elem The element to make clickable + * @param {boolean} add If FALSE, the listener is removed. Default: TRUE + * + * @return {HTMLElement} The clickable element + */ + DOM.makeClickable = (function() { + function clickCb(event) { + if (event.keyCode === 32 || event.keyCode === 13) { + event.preventDefault(); + event.target.click(); + } + } + var cb; + cb = function(elem, add) { + if ('undefined' === typeof add) add = true; + if (add) elem.addEventListener('keydown', clickCb); + else elem.removeEventListener('keydown', clickCb); + return elem; + }; + cb.cb = clickCb; + return cb; + })(); + + // ## Helper methods + + /** + * ### onFocusChange + * + * Helper function for DOM.onFocusIn and DOM.onFocusOut (cross-browser) + * + * Expects only one callback, either inCb, or outCb. + * + * @param {function|null} inCb Optional. Executed if page acquires focus, + * or NULL, to delete an existing callback. + * @param {function|null} outCb Optional. Executed if page loses focus, + * or NULL, to delete an existing callback. + * + * Kudos: http://stackoverflow.com/questions/1060008/ + * is-there-a-way-to-detect-if-a-browser-window-is-not-currently-active + * + * @see http://www.w3.org/TR/page-visibility/ + */ + onFocusChange = (function(document) { + var inFocusCb, outFocusCb, event, hidden, evtMap; + + if (!document) { + return function() { + JSUS.log('onFocusChange: no document detected.'); + return; + }; + } + + if ('hidden' in document) { + hidden = 'hidden'; + event = 'visibilitychange'; + } + else if ('mozHidden' in document) { + hidden = 'mozHidden'; + event = 'mozvisibilitychange'; + } + else if ('webkitHidden' in document) { + hidden = 'webkitHidden'; + event = 'webkitvisibilitychange'; + } + else if ('msHidden' in document) { + hidden = 'msHidden'; + event = 'msvisibilitychange'; + } + + evtMap = { + focus: true, focusin: true, pageshow: true, + blur: false, focusout: false, pagehide: false + }; + + function onchange(evt) { + var isHidden; + evt = evt || window.event; + // If event is defined as one from event Map. + if (evt.type in evtMap) isHidden = evtMap[evt.type]; + // Or use the hidden property. + else isHidden = this[hidden] ? true : false; + // Call the callback, if defined. + if (!isHidden) { if (inFocusCb) inFocusCb(); } + else { if (outFocusCb) outFocusCb(); } + } + + return function(inCb, outCb) { + var onchangeCb; + + if ('undefined' !== typeof inCb) inFocusCb = inCb; + else outFocusCb = outCb; + + onchangeCb = !inFocusCb && !outFocusCb ? null : onchange; + + // Visibility standard detected. + if (event) { + if (onchangeCb) document.addEventListener(event, onchange); + else document.removeEventListener(event, onchange); + } + else if ('onfocusin' in document) { + document.onfocusin = document.onfocusout = onchangeCb; + } + // All others. + else { + window.onpageshow = window.onpagehide = + window.onfocus = window.onblur = onchangeCb; + } + }; + })('undefined' !== typeof document ? document : null); + + /** + * ### changeTitle + * + * Changes title of page + * + * @param {string} title New title of the page + */ + changeTitle = function(title) { + if ('string' === typeof title) { + document.title = title; + } + else { + throw new TypeError('JSUS.changeTitle: title must be string. ' + + 'Found: ' + title); + } + }; + + JSUS.extend(DOM); + +})('undefined' !== typeof JSUS ? JSUS : module.parent.exports.JSUS); + +/** + * # EVAL + * Copyright(c) 2015 Stefano Balietti + * MIT Licensed + * + * Evaluation of strings as JavaScript commands + */ +(function(JSUS) { + + "use strict"; + + function EVAL() {} + + /** + * ## EVAL.eval + * + * Cross-browser eval function with context. + * + * If no context is passed a reference, `this` is used. + * + * In old IEs it will use _window.execScript_ instead. + * + * @param {string} str The command to executes + * @param {object} context Optional. Execution context. Defaults, `this` + * + * @return {mixed} The return value of the executed commands + * + * @see eval + * @see execScript + * @see JSON.parse + */ + EVAL.eval = function(str, context) { + var func; + if (!str) return; + context = context || this; + // Eval must be called indirectly + // i.e. eval.call is not possible + func = function(str) { + // TODO: Filter str. + str = '(' + str + ')'; + if ('undefined' !== typeof window && window.execScript) { + // Notice: execScript doesn’t return anything. + window.execScript('__my_eval__ = ' + str); + return __my_eval__; + } + else { + return eval(str); + } + }; + return func.call(context, str); + }; + + JSUS.extend(EVAL); + +})('undefined' !== typeof JSUS ? JSUS : module.parent.exports.JSUS); + +/** + * # OBJ + * Copyright(c) 2019 Stefano Balietti + * MIT Licensed + * + * Collection of static functions to manipulate JavaScript objects + */ +(function(JSUS) { + + "use strict"; + + function OBJ() {} + + var compatibility = null; + + if ('undefined' !== typeof JSUS.compatibility) { + compatibility = JSUS.compatibility(); + } + + /** + * ## OBJ.createObj + * + * Polyfill for Object.create (when missing) + */ + OBJ.createObj = (function() { + // From MDN Object.create (Polyfill) + if (typeof Object.create !== 'function') { + // Production steps of ECMA-262, Edition 5, 15.2.3.5 + // Reference: http://es5.github.io/#x15.2.3.5 + return (function() { + // To save on memory, use a shared constructor + function Temp() {} + + // make a safe reference to Object.prototype.hasOwnProperty + var hasOwn = Object.prototype.hasOwnProperty; + + return function(O) { + // 1. If Type(O) is not Object or Null + if (typeof O != 'object') { + throw new TypeError('Object prototype may only ' + + 'be an Object or null'); + } + + // 2. Let obj be the result of creating a new object as if + // by the expression new Object() where Object is the + // standard built-in constructor with that name + // 3. Set the [[Prototype]] internal property of obj to O. + Temp.prototype = O; + var obj = new Temp(); + Temp.prototype = null; + + // 4. If the argument Properties is present and not + // undefined, add own properties to obj as if by calling + // the standard built-in function Object.defineProperties + // with arguments obj and Properties. + if (arguments.length > 1) { + // Object.defineProperties does ToObject on + // its first argument. + var Properties = new Object(arguments[1]); + for (var prop in Properties) { + if (hasOwn.call(Properties, prop)) { + obj[prop] = Properties[prop]; + } + } + } + + // 5. Return obj + return obj; + }; + })(); + } + return Object.create; + })(); + + /** + * ## OBJ.equals + * + * Checks for deep equality between two objects, strings or primitive types + * + * All nested properties are checked, and if they differ in at least + * one returns FALSE, otherwise TRUE. + * + * Takes care of comparing the following special cases: + * + * - undefined + * - null + * - NaN + * - Infinity + * - {} + * - falsy values + * + * @param {object} o1 The first object + * @param {object} o2 The second object + * + * @return {boolean} TRUE if the objects are deeply equal + */ + OBJ.equals = function(o1, o2) { + var type1, type2, primitives, p; + type1 = typeof o1; + type2 = typeof o2; + + if (type1 !== type2) return false; + + if ('undefined' === type1 || 'undefined' === type2) { + return (o1 === o2); + } + if (o1 === null || o2 === null) { + return (o1 === o2); + } + if (('number' === type1 && isNaN(o1)) && + ('number' === type2 && isNaN(o2))) { + return (isNaN(o1) && isNaN(o2)); + } + + // Check whether arguments are not objects + primitives = {number: '', string: '', boolean: ''}; + if (type1 in primitives) { + return o1 === o2; + } + + if ('function' === type1) { + return o1.toString() === o2.toString(); + } + + for (p in o1) { + if (o1.hasOwnProperty(p)) { + + if ('undefined' === typeof o2[p] && + 'undefined' !== typeof o1[p]) return false; + + if (!o2[p] && o1[p]) return false; + + if ('function' === typeof o1[p]) { + if (o1[p].toString() !== o2[p].toString()) return false; + } + else + if (!OBJ.equals(o1[p], o2[p])) return false; + } + } + + // Check whether o2 has extra properties + // TODO: improve, some properties have already been checked! + for (p in o2) { + if (o2.hasOwnProperty(p)) { + if ('undefined' === typeof o1[p] && + 'undefined' !== typeof o2[p]) return false; + + if (!o1[p] && o2[p]) return false; + } + } + + return true; + }; + + /** + * ## OBJ.isEmpty + * + * Returns TRUE if an object has no own properties (supports other types) + * + * Map of input-type and return values: + * + * - undefined: TRUE + * - null: TRUE + * - string: TRUE if string === '' or if contains only spaces + * - number: FALSE if different from 0 + * - function: FALSE + * - array: TRUE, if it contains zero elements + * - object: TRUE, if it does not contain **own** properties + * + * Notice: for object, it is much faster than Object.keys(o).length === 0, + * because it does not pull out all keys. Own properties must be enumerable. + * + * @param {mixed} o The object (or other type) to check + * + * @return {boolean} TRUE, if the object is empty + */ + OBJ.isEmpty = function(o) { + var key; + if (!o) return true; + if ('string' === typeof o) return o.trim() === ''; + if ('number' === typeof o) return false; + if ('function' === typeof o) return false; + for (key in o) if (o.hasOwnProperty(key)) return false; + return true; + }; + + /** + * ## OBJ.size + * + * Counts the number of own properties of an object. + * + * Prototype chain properties are excluded. + * + * @param {object} obj The object to check + * + * @return {number} The number of properties in the object + */ + OBJ.size = OBJ.getListSize = function(obj) { + var n, key; + if (!obj) return 0; + if ('number' === typeof obj) return 0; + if ('string' === typeof obj) return 0; + + n = 0; + for (key in obj) { + if (obj.hasOwnProperty(key)) { + n++; + } + } + return n; + }; + + /** + * ## OBJ._obj2Array + * + * Explodes an object into an array of keys and values, + * according to the specified parameters. + * + * A fixed level of recursion can be set. + * + * @api private + * @param {object} obj The object to convert in array + * @param {boolean} keyed TRUE, if also property names should be included. + * Defaults, FALSE + * @param {number} level Optional. The level of recursion. + * Defaults, undefined + * + * @return {array} The converted object + */ + OBJ._obj2Array = function(obj, keyed, level, cur_level) { + var result, key; + if ('object' !== typeof obj) return [obj]; + + if (level) { + cur_level = ('undefined' !== typeof cur_level) ? cur_level : 1; + if (cur_level > level) return [obj]; + cur_level = cur_level + 1; + } + + result = []; + for (key in obj) { + if (obj.hasOwnProperty(key)) { + if (keyed) result.push(key); + if ('object' === typeof obj[key]) { + result = result.concat(OBJ._obj2Array(obj[key], keyed, + level, cur_level)); + } + else { + result.push(obj[key]); + } + } + } + return result; + }; + + /** + * ## OBJ.obj2Array + * + * Converts an object into an array, keys are lost + * + * Recursively put the values of the properties of an object into + * an array and returns it. + * + * The level of recursion can be set with the parameter level. + * By default recursion has no limit, i.e. that the whole object + * gets totally unfolded into an array. + * + * @param {object} obj The object to convert in array + * @param {number} level Optional. The level of recursion. Defaults, + * undefined + * + * @return {array} The converted object + * + * @see OBJ._obj2Array + * @see OBJ.obj2KeyedArray + */ + OBJ.obj2Array = function(obj, level) { + return OBJ._obj2Array(obj, false, level); + }; + + /** + * ## OBJ.obj2KeyedArray + * + * Converts an object into array, keys are preserved + * + * Creates an array containing all keys and values of an object and + * returns it. + * + * @param {object} obj The object to convert in array + * @param {number} level Optional. The level of recursion. Defaults, + * undefined + * + * @return {array} The converted object + * + * @see OBJ.obj2Array + */ + OBJ.obj2KeyedArray = OBJ.obj2KeyArray = function(obj, level) { + return OBJ._obj2Array(obj, true, level); + }; + + /** + * ## OBJ.obj2QueryString + * + * Creates a querystring with the key-value pairs of the given object. + * + * @param {object} obj The object to convert + * + * @return {string} The created querystring + * + * Kudos: + * @see http://stackoverflow.com/a/1714899/3347292 + */ + OBJ.obj2QueryString = function(obj) { + var str; + var key; + + if ('object' !== typeof obj) { + throw new TypeError( + 'JSUS.objectToQueryString: obj must be object.'); + } + + str = []; + for (key in obj) { + if (obj.hasOwnProperty(key)) { + str.push(encodeURIComponent(key) + '=' + + encodeURIComponent(obj[key])); + } + } + + return '?' + str.join('&'); + }; + + /** + * ## OBJ.keys + * + * Returns all the keys of an object until desired level of nestedness + * + * The second parameter can be omitted, and the level can be specified + * inside the options object passed as second parameter. + * + * @param {object} obj The object from which extract the keys + * @param {number} level Optional. How many nested levels to scan. + * Default: 0, meaning 0 recursion, i.e., only first level keys. + * @param {object} options Optional. Configuration options: + * + * - type: 'all': all keys (default), + * 'level': keys of the specified level, + * 'leaf': keys that are leaves, i.e., keys that are at the + * the desired level or that do not point to an object + * - concat: true/false: If TRUE, keys are prefixed by parent keys + * - separator: a character to inter between parent and children keys; + * (default: '.') + * - distinct: if TRUE, only unique keys are returned (default: false) + * - parent: the name of initial parent key (default: '') + * - array: an array to which the keys will be appended (default: []) + * - skip: an object containing keys to skip + * - cb: a callback to be applied to every key before adding to results. + * The return value of the callback is interpreted as follows: + * - string|number: inserted as it is + * - array: concatenated + * - undefined: the original key is inserted + * - null: nothing is inserted + * + * @return {array} The array containing the extracted keys + * + * @see Object.keys + */ + OBJ.keys = (function() { + return function(obj, level, options) { + var keys, type, allKeys, leafKeys, levelKeys; + var separator, myLevel, curParent; + + if (arguments.length === 2 && 'object' === typeof level) { + options = level; + level = options.level; + } + + options = options || {}; + + type = options.type ? options.type.toLowerCase() : 'all'; + if (type === 'all') allKeys = true; + else if (type === 'leaf') leafKeys = true; + else if (type === 'level') levelKeys = true; + else throw new Error('keys: unknown type option: ' + type); + + if (options.cb && 'function' !== typeof options.cb) { + throw new TypeError('JSUS.keys: options.cb must be function ' + + 'or undefined. Found: ' + options.cb); + } + + if ('undefined' === typeof level) myLevel = 0; + else if ('number' === typeof level) myLevel = level; + else if ('string' === typeof level) myLevel = parseInt(level, 10); + if ('number' !== typeof myLevel || isNaN(myLevel)) { + throw new Error('JSUS.keys: level must be number, undefined ' + + 'or a parsable string. Found: ' + level); + } + // No keys at level -1; + if (level < 0) return []; + + if (options.concat) { + if ('undefined' === typeof options.separator) separator = '.'; + else separator = options.separator; + } + + if (options.parent) curParent = options.parent + separator; + else curParent = ''; + + if (!options.concat && options.distinct) keys = {}; + + return _keys(obj, myLevel, 0, curParent, options.concat, + allKeys, leafKeys, levelKeys, separator, + options.array || [], keys, options.skip || {}, + options.cb); + } + + function _keys(obj, level, curLevel, curParent, + concatKeys, allKeys, leafKeys, levelKeys, + separator, res, uniqueKeys, skipKeys, cb) { + + var key, isLevel, isObj, tmp; + isLevel = curLevel === level; + for (key in obj) { + if (obj.hasOwnProperty(key)) { + + isObj = 'object' === typeof obj[key]; + if (allKeys || + (leafKeys && (isLevel || !isObj)) || + (levelKeys && isLevel)) { + + if (concatKeys) { + tmp = curParent + key; + if (!skipKeys[tmp]) { + if (cb) _doCb(tmp, res, cb); + else res.push(tmp); + } + } + else if (!skipKeys[key]) { + if (uniqueKeys){ + if (!uniqueKeys[key]) { + if (cb) _doCb(key, res, cb); + else res.push(key); + uniqueKeys[key] = true; + } + } + else { + if (cb) _doCb(key, res, cb); + else res.push(key); + } + } + } + if (isObj && (curLevel < level)) { + _keys(obj[key], level, (curLevel+1), + concatKeys ? curParent + key + separator : key, + concatKeys, allKeys, leafKeys, levelKeys, + separator, res, uniqueKeys, skipKeys, cb); + } + } + } + return res; + } + + function _doCb(key, res, cb) { + var tmp; + tmp = cb(key); + // If string, substitute it. + if ('string' === typeof tmp || 'number' === typeof tmp) { + res.push(tmp); + } + // If array, expand it. + else if (JSUS.isArray(tmp) && tmp.length) { + if (tmp.length < 4) { + res.push(tmp[0]); + if (tmp.length > 1) { + res.push(tmp[1]); + if (tmp.length > 2) { + res.push(tmp[2]); + } + } + } + else { + (function() { + var i = -1, len = tmp.length; + for ( ; ++i < len ; ) { + res.push(tmp[i]); + } + })(tmp, res); + } + } + else if ('undefined' === typeof tmp) { + res.push(key); + } + // Else, e.g. null, ignore it. + } + })(); + + + /** + * ## OBJ.implode + * + * Separates each property into a new object and returns them into an array + * + * E.g. + * + * ```javascript + * var a = { b:2, c: {a:1}, e:5 }; + * OBJ.implode(a); // [{b:2}, {c:{a:1}}, {e:5}] + * ``` + * + * @param {object} obj The object to implode + * + * @return {array} The array containing all the imploded properties + */ + OBJ.implode = OBJ.implodeObj = function(obj) { + var result, key, o; + if (!obj) return []; + result = []; + for (key in obj) { + if (obj.hasOwnProperty(key)) { + o = {}; + o[key] = obj[key]; + result.push(o); + } + } + return result; + }; + + /** + * ## OBJ.clone + * + * Creates a perfect copy of the object passed as parameter + * + * Recursively scans all the properties of the object to clone. + * Properties of the prototype chain are copied as well. + * + * Primitive types and special values are returned as they are. + * + * @param {object} obj The object to clone + * + * @return {object} The clone of the object + */ + OBJ.clone = function(obj) { + var clone, i, value; + if (!obj) return obj; + if ('number' === typeof obj) return obj; + if ('string' === typeof obj) return obj; + if ('boolean' === typeof obj) return obj; + // NaN and +-Infinity are numbers, so no check is necessary. + + if ('function' === typeof obj) { + clone = function() { + var len, args; + len = arguments.length; + if (!len) return obj.call(clone); + else if (len === 1) return obj.call(clone, arguments[0]); + else if (len === 2) { + return obj.call(clone, arguments[0], arguments[1]); + } + else { + args = new Array(len); + for (i = 0; i < len; i++) { + args[i] = arguments[i]; + } + return obj.apply(clone, args); + } + }; + } + else { + clone = Object.prototype.toString.call(obj) === '[object Array]' ? + [] : {}; + } + for (i in obj) { + // It is not NULL and it is an object. + // Even if it is an array we need to use CLONE, + // because `slice()` does not clone arrays of objects. + if (obj[i] && 'object' === typeof obj[i]) { + value = OBJ.clone(obj[i]); + } + else { + value = obj[i]; + } + + if (obj.hasOwnProperty(i)) { + clone[i] = value; + } + else { + // We know if object.defineProperty is available. + if (compatibility && compatibility.defineProperty) { + Object.defineProperty(clone, i, { + value: value, + writable: true, + configurable: true + }); + } + else { + setProp(clone, i, value); + } + } + } + return clone; + }; + + function setProp(clone, i, value) { + try { + Object.defineProperty(clone, i, { + value: value, + writable: true, + configurable: true + }); + } + catch(e) { + clone[i] = value; + } + } + + + /** + * ## OBJ.classClone + * + * Creates a copy (keeping class) of the object passed as parameter + * + * Recursively scans all the properties of the object to clone. + * The clone is an instance of the type of obj. + * + * @param {object} obj The object to clone + * @param {Number} depth how deep the copy should be + * + * @return {object} The clone of the object + */ + OBJ.classClone = function(obj, depth) { + var clone, i; + if (depth === 0) { + return obj; + } + + if (obj && 'object' === typeof obj) { + clone = Object.prototype.toString.call(obj) === '[object Array]' ? + [] : JSUS.createObj(obj.constructor.prototype); + + for (i in obj) { + if (obj.hasOwnProperty(i)) { + if (obj[i] && 'object' === typeof obj[i]) { + clone[i] = JSUS.classClone(obj[i], depth - 1); + } + else { + clone[i] = obj[i]; + } + } + } + return clone; + } + else { + return JSUS.clone(obj); + } + }; + + /** + * ## OBJ.join + * + * Performs a *left* join on the keys of two objects + * + * Creates a copy of obj1, and in case keys overlap + * between obj1 and obj2, the values from obj2 are taken. + * + * Returns a new object, the original ones are not modified. + * + * E.g. + * + * ```javascript + * var a = { b:2, c:3, e:5 }; + * var b = { a:10, b:2, c:100, d:4 }; + * OBJ.join(a, b); // { b:2, c:100, e:5 } + * ``` + * + * @param {object} obj1 The object where the merge will take place + * @param {object} obj2 The merging object + * + * @return {object} The joined object + * + * @see OBJ.merge + */ + OBJ.join = function(obj1, obj2) { + var clone, i; + clone = OBJ.clone(obj1); + if (!obj2) return clone; + for (i in clone) { + if (clone.hasOwnProperty(i)) { + if ('undefined' !== typeof obj2[i]) { + if ('object' === typeof obj2[i]) { + clone[i] = OBJ.join(clone[i], obj2[i]); + } else { + clone[i] = obj2[i]; + } + } + } + } + return clone; + }; + + /** + * ## OBJ.merge + * + * Merges two objects in one + * + * In case keys overlap the values from obj2 are taken. + * + * Only own properties are copied. + * + * Returns a new object, the original ones are not modified. + * + * E.g. + * + * ```javascript + * var a = { a:1, b:2, c:3 }; + * var b = { a:10, b:2, c:100, d:4 }; + * OBJ.merge(a, b); // { a: 10, b: 2, c: 100, d: 4 } + * ``` + * + * @param {object} obj1 The object where the merge will take place + * @param {object} obj2 The merging object + * + * @return {object} The merged object + * + * @see OBJ.join + * @see OBJ.mergeOnKey + */ + OBJ.merge = function(obj1, obj2) { + var clone, i; + // Checking before starting the algorithm + if (!obj1 && !obj2) return false; + if (!obj1) return OBJ.clone(obj2); + if (!obj2) return OBJ.clone(obj1); + + clone = OBJ.clone(obj1); + for (i in obj2) { + + if (obj2.hasOwnProperty(i)) { + // it is an object and it is not NULL + if (obj2[i] && 'object' === typeof obj2[i]) { + // If we are merging an object into + // a non-object, we need to cast the + // type of obj1 + if ('object' !== typeof clone[i]) { + if (Object.prototype.toString.call(obj2[i]) === + '[object Array]') { + + clone[i] = []; + } + else { + clone[i] = {}; + } + } + clone[i] = OBJ.merge(clone[i], obj2[i]); + } + else { + clone[i] = obj2[i]; + } + } + } + return clone; + }; + + /** + * ## OBJ.mixin + * + * Adds all the properties of obj2 into obj1 + * + * Original object is modified. + * + * @param {object} obj1 The object to which the new properties will be added + * @param {object} obj2 The mixin-in object + * + * @return {object} obj1 + */ + OBJ.mixin = function(obj1, obj2) { + var i; + if (!obj1 && !obj2) return; + if (!obj1) return obj2; + if (!obj2) return obj1; + for (i in obj2) { + obj1[i] = obj2[i]; + } + return obj1; + }; + + /** + * ## OBJ.mixout + * + * Copies only non-overlapping properties from obj2 to obj1 + * + * Check only if a property is defined, not its value. + * Original object is modified. + * + * @param {object} obj1 The object to which the new properties will be added + * @param {object} obj2 The mixin-in object + * + * @return {object} obj1 + */ + OBJ.mixout = function(obj1, obj2) { + var i; + if (!obj1 && !obj2) return; + if (!obj1) return obj2; + if (!obj2) return obj1; + for (i in obj2) { + if ('undefined' === typeof obj1[i]) obj1[i] = obj2[i]; + } + return obj1; + }; + + /** + * ## OBJ.mixcommon + * + * Copies only overlapping properties from obj2 to obj1 + * + * Check only if a property is defined, not its value. + * Original object is modified. + * + * @param {object} obj1 The object to which the new properties will be added + * @param {object} obj2 The mixin-in object + * + * @return {object} obj1 + */ + OBJ.mixcommon = function(obj1, obj2) { + var i; + if (!obj1 && !obj2) return; + if (!obj1) return obj2; + if (!obj2) return obj1; + for (i in obj2) { + if ('undefined' !== typeof obj1[i]) obj1[i] = obj2[i]; + } + return obj1; + }; + + /** + * ## OBJ.mergeOnKey + * + * Merges the properties of obj2 into a new property named 'key' in obj1. + * + * Returns a new object, the original ones are not modified. + * + * This method is useful when we want to merge into a larger + * configuration (e.g. with properties min, max, value) object, another one + * that contains just a subset of properties (e.g. value). + * + * @param {object} obj1 The object where the merge will take place + * @param {object} obj2 The merging object + * @param {string} key The name of property under which the second object + * will be merged + * + * @return {object} The merged object + * + * @see OBJ.merge + */ + OBJ.mergeOnKey = function(obj1, obj2, key) { + var clone, i; + clone = OBJ.clone(obj1); + if (!obj2 || !key) return clone; + for (i in obj2) { + if (obj2.hasOwnProperty(i)) { + if (!clone[i] || 'object' !== typeof clone[i]) { + clone[i] = {}; + } + clone[i][key] = obj2[i]; + } + } + return clone; + }; + + /** + * ## OBJ.subobj | subObj + * + * Creates a copy of an object containing only the properties + * passed as second parameter + * + * The parameter select can be an array of strings, or the name + * of a property. + * + * Use '.' (dot) to point to a nested property, however if a property + * with a '.' in the name is found, it will be used first. + * + * @param {object} o The object to dissect + * @param {string|array} select The selection of properties to extract + * + * @return {object} The subobject with the properties from the parent + * + * @see OBJ.getNestedValue + */ + OBJ.subobj = OBJ.subObj = function(o, select) { + var out, i, key; + if (!o) return false; + out = {}; + if (!select) return out; + if (!(select instanceof Array)) select = [select]; + for (i=0; i < select.length; i++) { + key = select[i]; + if (o.hasOwnProperty(key)) { + out[key] = o[key]; + } + else if (OBJ.hasOwnNestedProperty(key, o)) { + OBJ.setNestedValue(key, OBJ.getNestedValue(key, o), out); + } + } + return out; + }; + + /** + * ## OBJ.skim + * + * Creates a copy of an object with some of the properties removed + * + * The parameter `remove` can be an array of strings, or the name + * of a property. + * + * Use '.' (dot) to point to a nested property, however if a property + * with a '.' in the name is found, it will be deleted first. + * + * @param {object} o The object to dissect + * @param {string|array} remove The selection of properties to remove + * + * @return {object} The subobject with the properties from the parent + * + * @see OBJ.getNestedValue + */ + OBJ.skim = function(o, remove) { + var out, i; + if (!o) return false; + out = OBJ.clone(o); + if (!remove) return out; + if (!(remove instanceof Array)) remove = [remove]; + for (i = 0; i < remove.length; i++) { + if (out.hasOwnProperty(i)) { + delete out[i]; + } + else { + OBJ.deleteNestedKey(remove[i], out); + } + } + return out; + }; + + + /** + * ## OBJ.setNestedValue + * + * Sets the value of a nested property of an object and returns it. + * + * If the object is not passed a new one is created. + * If the nested property is not existing, a new one is created. + * + * Use '.' (dot) to point to a nested property. + * + * The original object is modified. + * + * @param {string} str The path to the value + * @param {mixed} value The value to set + * + * @return {object|boolean} The modified object, or FALSE if error + * occurrs + * + * @see OBJ.getNestedValue + * @see OBJ.deleteNestedKey + */ + OBJ.setNestedValue = function(str, value, obj) { + var keys, k; + if (!str) { + JSUS.log('Cannot set value of undefined property', 'ERR'); + return false; + } + obj = ('object' === typeof obj) ? obj : {}; + keys = str.split('.'); + if (keys.length === 1) { + obj[str] = value; + return obj; + } + k = keys.shift(); + obj[k] = OBJ.setNestedValue(keys.join('.'), value, obj[k]); + return obj; + }; + + /** + * ## OBJ.getNestedValue + * + * Returns the value of a property of an object, as defined + * by a path string. + * + * Use '.' (dot) to point to a nested property. + * + * Returns undefined if the nested property does not exist. + * + * E.g. + * + * ```javascript + * var o = { a:1, b:{a:2} }; + * OBJ.getNestedValue('b.a', o); // 2 + * ``` + * + * @param {string} str The path to the value + * @param {object} obj The object from which extract the value + * + * @return {mixed} The extracted value + * + * @see OBJ.setNestedValue + * @see OBJ.deleteNestedKey + */ + OBJ.getNestedValue = function(str, obj) { + var keys, k; + if (!obj) return; + keys = str.split('.'); + if (keys.length === 1) { + return obj[str]; + } + k = keys.shift(); + return OBJ.getNestedValue(keys.join('.'), obj[k]); + }; + + /** + * ## OBJ.deleteNestedKey + * + * Deletes a property from an object, as defined by a path string + * + * Use '.' (dot) to point to a nested property. + * + * The original object is modified. + * + * E.g. + * + * ```javascript + * var o = { a:1, b:{a:2} }; + * OBJ.deleteNestedKey('b.a', o); // { a:1, b: {} } + * ``` + * + * @param {string} str The path string + * @param {object} obj The object from which deleting a property + * @param {boolean} TRUE, if the property was existing, and then deleted + * + * @see OBJ.setNestedValue + * @see OBJ.getNestedValue + */ + OBJ.deleteNestedKey = function(str, obj) { + var keys, k; + if (!obj) return; + keys = str.split('.'); + if (keys.length === 1) { + delete obj[str]; + return true; + } + k = keys.shift(); + if ('undefined' === typeof obj[k]) { + return false; + } + return OBJ.deleteNestedKey(keys.join('.'), obj[k]); + }; + + /** + * ## OBJ.hasOwnNestedProperty + * + * Returns TRUE if a (nested) property exists + * + * Use '.' to specify a nested property. + * + * E.g. + * + * ```javascript + * var o = { a:1, b:{a:2} }; + * OBJ.hasOwnNestedProperty('b.a', o); // TRUE + * ``` + * + * @param {string} str The path of the (nested) property + * @param {object} obj The object to test + * + * @return {boolean} TRUE, if the (nested) property exists + */ + OBJ.hasOwnNestedProperty = function(str, obj) { + var keys, k; + if (!obj) return false; + keys = str.split('.'); + if (keys.length === 1) { + return obj.hasOwnProperty(str); + } + k = keys.shift(); + return OBJ.hasOwnNestedProperty(keys.join('.'), obj[k]); + }; + + /** + * ## OBJ.split + * + * Splits an object along a specified dimension + * + * All fragments are returned in an array (as copies). + * + * It creates as many new objects as the number of properties + * contained in the specified dimension. E.g. + * + * ```javascript + * var o = { a: 1, + * b: {c: 2, + * d: 3 + * }, + * e: 4 + * }; + * + * o = OBJ.split(o, 'b'); + * + * // o becomes: + * + * [{ a: 1, + * b: {c: 2}, + * e: 4 + * }, + * { a: 1, + * b: {d: 3}, + * e: 4 + * }]; + * ``` + * + * @param {object} o The object to split + * @param {string} key The name of the property to split + * @param {number} l Optional. The recursion level. Default: 1. + * @param {boolean} positionAsKey Optional. If TRUE, the position + * of an element in the array to split will be used as key. + * + * @return {array} A list of copies of the object with split values + */ + OBJ.split = (function() { + var makeClone, splitValue; + var model, level, _key, posAsKeys; + + makeClone = function(value, out, keys) { + var i, len, tmp, copy; + copy = JSUS.clone(model); + + switch(keys.length) { + case 0: + copy[_key] = JSUS.clone(value); + break; + case 1: + copy[_key][keys[0]] = JSUS.clone(value); + break; + case 2: + copy[_key][keys[0]] = {}; + copy[_key][keys[0]][keys[1]] = JSUS.clone(value); + break; + default: + i = -1, len = keys.length-1; + tmp = copy[_key]; + for ( ; ++i < len ; ) { + tmp[keys[i]] = {}; + tmp = tmp[keys[i]]; + } + tmp[keys[keys.length-1]] = JSUS.clone(value); + } + out.push(copy); + return; + }; + + splitValue = function(value, out, curLevel, keysArray) { + var i, curPosAsKey; + + // level == 0 means no limit. + if (level && (curLevel >= level)) { + makeClone(value, out, keysArray); + } + else { + + curPosAsKey = posAsKeys || !JSUS.isArray(value); + + for (i in value) { + if (value.hasOwnProperty(i)) { + + if ('object' === typeof value[i] && + (level && ((curLevel+1) <= level))) { + + splitValue(value[i], out, (curLevel+1), + curPosAsKey ? + keysArray.concat(i) : keysArray); + } + else { + makeClone(value[i], out, curPosAsKey ? + keysArray.concat(i) : keysArray); + } + } + } + } + }; + + return function(o, key, l, positionAsKey) { + var out; + if ('object' !== typeof o) { + throw new TypeError('JSUS.split: o must be object. Found: ' + + o); + } + if ('string' !== typeof key || key.trim() === '') { + throw new TypeError('JSUS.split: key must a non-empty ' + + 'string. Found: ' + key); + } + if (l && ('number' !== typeof l || l < 0)) { + throw new TypeError('JSUS.split: l must a non-negative ' + + 'number or undefined. Found: ' + l); + } + model = JSUS.clone(o); + if ('object' !== typeof o[key]) return [model]; + // Init. + out = []; + _key = key; + model[key] = {}; + level = 'undefined' === typeof l ? 1 : l; + posAsKeys = positionAsKey; + // Recursively compute split. + splitValue(o[key], out, 0, []); + // Cleanup. + _key = undefined; + model = undefined; + level = undefined; + posAsKeys = undefined; + // Return. + return out; + }; + })(); + + /** + * ## OBJ.melt + * + * Creates a new object with specific combination of properties - values + * + * The values are assigned cyclically to the properties, so that + * they do not need to have the same length. E.g. + * + * ```javascript + * J.melt(['a','b','c'], [1,2]); // { a: 1, b: 2, c: 1 } + * ``` + * @param {array} keys The names of the keys to add to the object + * @param {array} values The values to associate to the keys + * + * @return {object} A new object with keys and values melted together + */ + OBJ.melt = function(keys, values) { + var o = {}, valen = values.length; + for (var i = 0; i < keys.length; i++) { + o[keys[i]] = values[i % valen]; + } + return o; + }; + + /** + * ## OBJ.uniqueKey + * + * Creates a random unique key name for a collection + * + * User can specify a tentative unique key name, and if already + * existing an incremental index will be added as suffix to it. + * + * Notice: the method does not actually create the key + * in the object, but it just returns the name. + * + * @param {object} obj The collection for which a unique key will be created + * @param {string} prefixName Optional. A tentative key name. Defaults, + * a 15-digit random number + * @param {number} stop Optional. The number of tries before giving up + * searching for a unique key name. Defaults, 1000000. + * + * @return {string|undefined} The unique key name, or undefined if it was + * not found + */ + OBJ.uniqueKey = function(obj, prefixName, stop) { + var name, duplicateCounter; + if (!obj) { + JSUS.log('Cannot find unique name in undefined object', 'ERR'); + return; + } + duplicateCounter = 1; + prefixName = '' + (prefixName || + Math.floor(Math.random()*1000000000000000)); + stop = stop || 1000000; + name = prefixName; + while (obj[name]) { + name = prefixName + duplicateCounter; + duplicateCounter++; + if (duplicateCounter > stop) { + return; + } + } + return name; + }; + + /** + * ## OBJ.randomKey + * + * Returns a random key from an existing object + * + * @param {object} obj The object from which the key will be extracted + * + * @return {string} The random key + */ + OBJ.randomKey = function(obj) { + var keys; + if ('object' !== typeof obj) { + throw new TypeError('OBJ.randomKey: obj must be object. ' + + 'Found: ' + obj); + } + keys = Object.keys(obj); + return keys[ keys.length * Math.random() << 0]; + }; + + /** + * ## OBJ.augment + * + * Pushes the values of the properties of an object into another one + * + * User can specifies the subset of keys from both objects + * that will subject to augmentation. The values of the other keys + * will not be changed + * + * Notice: the method modifies the first input paramteer + * + * E.g. + * + * ```javascript + * var a = { a:1, b:2, c:3 }; + * var b = { a:10, b:2, c:100, d:4 }; + * OBJ.augment(a, b); // { a: [1, 10], b: [2, 2], c: [3, 100]} + * + * OBJ.augment(a, b, ['b', 'c', 'd']); + * // { a: 1, b: [2, 2], c: [3, 100], d: [4]}); + * + * ``` + * + * @param {object} obj1 The object whose properties will be augmented + * @param {object} obj2 The augmenting object + * @param {array} key Optional. Array of key names common to both objects + * taken as the set of properties to augment + */ + OBJ.augment = function(obj1, obj2, keys) { + var i, k; + keys = keys || OBJ.keys(obj1); + + for (i = 0 ; i < keys.length; i++) { + k = keys[i]; + if ('undefined' !== typeof obj1[k] && + Object.prototype.toString.call(obj1[k]) !== '[object Array]') { + obj1[k] = [obj1[k]]; + } + if ('undefined' !== obj2[k]) { + if (!obj1[k]) obj1[k] = []; + obj1[k].push(obj2[k]); + } + } + }; + + + /** + * ## OBJ.pairwiseWalk + * + * Executes a callback on all pairs of attributes with the same name + * + * The results of each callback are aggregated in a new object under the + * same property name. + * + * Does not traverse nested objects, and properties of the prototype + * are excluded. + * + * Returns a new object, the original ones are not modified. + * + * E.g. + * + * ```javascript + * var a = { b:2, c:3, d:5 }; + * var b = { a:10, b:2, c:100, d:4 }; + * var sum = function(a,b) { + * if ('undefined' !== typeof a) { + * return 'undefined' !== typeof b ? a + b : a; + * } + * return b; + * }; + * OBJ.pairwiseWalk(a, b, sum); // { a:10, b:4, c:103, d:9 } + * ``` + * + * @param {object} o1 The first object + * @param {object} o2 The second object + * + * @return {object} The object aggregating the results + */ + OBJ.pairwiseWalk = function(o1, o2, cb) { + var i, out; + if (!o1 && !o2) return; + if (!o1) return o2; + if (!o2) return o1; + + out = {}; + for (i in o1) { + if (o1.hasOwnProperty(i)) { + out[i] = o2.hasOwnProperty(i) ? cb(o1[i], o2[i]) : cb(o1[i]); + } + } + + for (i in o2) { + if (o2.hasOwnProperty(i)) { + if ('undefined' === typeof out[i]) { + out[i] = cb(undefined, o2[i]); + } + } + } + return out; + }; + + /** + * ## OBJ.getKeyByValue + * + * Returns the key/s associated with a specific value + * + * Uses OBJ.equals so it can perform complicated comparisons of + * the value of the keys. + * + * Properties of the prototype are not skipped. + * + * @param {object} obj The object to search + * @param {mixed} value The value to match + * @param {boolean} allKeys Optional. If TRUE, all keys with the + * specific value are returned. Default FALSE + * + * @return {object} The object aggregating the results + * + * @see OBJ.equals + */ + OBJ.getKeyByValue = function(obj, value, allKeys) { + var key, out; + if ('object' !== typeof obj) { + throw new TypeError('OBJ.getKeyByValue: obj must be object. ' + + 'Found: ' + obj); + } + if (allKeys) out = []; + for (key in obj) { + if (obj.hasOwnProperty(key) ) { + if (OBJ.equals(value, obj[key])) { + if (!allKeys) return key; + else out.push(key); + } + } + } + return out; + }; + + /** + * ## OBJ.reverseObj + * + * Returns a new object where they keys and values are switched + * + * @param {object} obj The object to reverse + * @param {function} cb Optional. A callback processing a key-value pair. + * Takes as inputs current key and value and must return an array with + * updated key and value: [ newKey, newValue ]. + * + * @return {object} The reversed object + */ + OBJ.reverseObj = function(o, cb) { + var k, res; + if (cb && 'function' !== typeof cb) { + throw new TypeError('OBJ.reverseObj: cb must be function or ' + + 'undefined. Found: ' + cb); + } + res = {}; + if (!o) return res; + for (k in o) { + if (o.hasOwnProperty(k)) { + if (cb) { + k = cb(k, o[k]); + res[k[1]] = res[k[0]]; + } + else { + res[o[k]] = k; + } + } + } + return res; + }; + + JSUS.extend(OBJ); + +})('undefined' !== typeof JSUS ? JSUS : module.parent.exports.JSUS); + +/** + * # RANDOM + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * Generates pseudo-random numbers + */ +(function(JSUS) { + + "use strict"; + + function RANDOM() {} + + /** + * ## RANDOM.random + * + * Generates a pseudo-random floating point number in interval [a,b) + * + * Interval is a inclusive and b exclusive. + * + * If b is undefined, the interval is [0, a). + * + * If both a and b are undefined the interval is [0, 1) + * + * @param {number} a Optional. The lower limit, or the upper limit + * if b is undefined + * @param {number} b Optional. The upper limit + * + * @return {number} A random floating point number in [a,b) + */ + RANDOM.random = function(a, b) { + var c; + if ('undefined' === typeof b) { + if ('undefined' === typeof a) { + return Math.random(); + } + else { + b = a; + a = 0; + } + } + if (a === b) return a; + + if (b < a) { + c = a; + a = b; + b = c; + } + return (Math.random() * (b - a)) + a; + }; + + /** + * ## RANDOM.randomInt + * + * Generates a pseudo-random integer between (a,b] a exclusive, b inclusive + * + * @TODO: Change to interval [a,b], and allow 1 parameter for [0,a) + * + * @param {number} a The lower limit + * @param {number} b The upper limit + * + * @return {number} A random integer in (a,b] + * + * @see RANDOM.random + */ + RANDOM.randomInt = function(a, b) { + if (a === b) return a; + return Math.floor(RANDOM.random(a, b) + 1); + }; + + /** + * ## RANDOM.randomDate + * + * Generates a pseudo-random date between + * + * @param {Date} startDate Optional. The lower date. Default: 01-01-1900. + * @param {Date} endDate Optional. The upper date. Default: today. + * + * @return {number} A random date in the chosen interval + * + * @see RANDOM.randomDate + */ + RANDOM.randomDate = (function() { + function isValidDate(date) { + return date && + Object.prototype.toString.call(date) === "[object Date]" && + !isNaN(date); + } + return function(startDate, endDate) { + if ('undefined' === typeof startDate) { + startDate = new Date("1900"); + } + else if (!isValidDate(startDate)) { + throw new TypeError('randomDate: startDate must be a valid ' + + 'date. Found: ' + startDate); + } + if ('undefined' === typeof endDate) { + endDate = new Date(); + } + else if (!isValidDate(endDate)) { + throw new TypeError('randomDate: endDate must be a valid ' + + 'date or undefined. Found: ' + endDate); + } + return new Date(startDate.getTime() + Math.random() * + (endDate.getTime() - startDate.getTime())); + }; + })(); + + /** + * ## RANDOM.sample + * + * Generates a randomly shuffled sequence of numbers in [a,b)] + * + * Both _a_ and _b_ are included in the interval. + * + * @param {number} a The lower limit + * @param {number} b The upper limit + * + * @return {array} The randomly shuffled sequence. + * + * @see RANDOM.seq + */ + RANDOM.sample = function(a, b) { + var out; + out = JSUS.seq(a,b); + if (!out) return false; + return JSUS.shuffle(out); + }; + + /** + * ## RANDOM.getNormalGenerator + * + * Returns a new generator of normally distributed pseudo random numbers + * + * The generator is independent from RANDOM.nextNormal + * + * @return {function} An independent generator + * + * @see RANDOM.nextNormal + */ + RANDOM.getNormalGenerator = function() { + + return (function() { + + var oldMu, oldSigma; + var x2, multiplier, genReady; + + return function normal(mu, sigma) { + + var x1, u1, u2, v1, v2, s; + + if ('number' !== typeof mu) { + throw new TypeError('nextNormal: mu must be number.'); + } + if ('number' !== typeof sigma) { + throw new TypeError('nextNormal: sigma must be number.'); + } + + if (mu !== oldMu || sigma !== oldSigma) { + genReady = false; + oldMu = mu; + oldSigma = sigma; + } + + if (genReady) { + genReady = false; + return (sigma * x2) + mu; + } + + u1 = Math.random(); + u2 = Math.random(); + + // Normalize between -1 and +1. + v1 = (2 * u1) - 1; + v2 = (2 * u2) - 1; + + s = (v1 * v1) + (v2 * v2); + + // Condition is true on average 1.27 times, + // with variance equal to 0.587. + if (s >= 1) { + return normal(mu, sigma); + } + + multiplier = Math.sqrt(-2 * Math.log(s) / s); + + x1 = v1 * multiplier; + x2 = v2 * multiplier; + + genReady = true; + + return (sigma * x1) + mu; + + }; + })(); + }; + + /** + * ## RANDOM.nextNormal + * + * Generates random numbers with Normal Gaussian distribution. + * + * User must specify the expected mean, and standard deviation a input + * parameters. + * + * Implements the Polar Method by Knuth, "The Art Of Computer + * Programming", p. 117. + * + * @param {number} mu The mean of the distribution + * param {number} sigma The standard deviation of the distribution + * + * @return {number} A random number following a Normal Gaussian distribution + * + * @see RANDOM.getNormalGenerator + */ + RANDOM.nextNormal = RANDOM.getNormalGenerator(); + + /** + * ## RANDOM.nextLogNormal + * + * Generates random numbers with LogNormal distribution. + * + * User must specify the expected mean, and standard deviation of the + * underlying gaussian distribution as input parameters. + * + * @param {number} mu The mean of the gaussian distribution + * @param {number} sigma The standard deviation of the gaussian distribution + * + * @return {number} A random number following a LogNormal distribution + * + * @see RANDOM.nextNormal + */ + RANDOM.nextLogNormal = function(mu, sigma) { + if ('number' !== typeof mu) { + throw new TypeError('nextLogNormal: mu must be number.'); + } + if ('number' !== typeof sigma) { + throw new TypeError('nextLogNormal: sigma must be number.'); + } + return Math.exp(RANDOM.nextNormal(mu, sigma)); + }; + + /** + * ## RANDOM.nextExponential + * + * Generates random numbers with Exponential distribution. + * + * User must specify the lambda the _rate parameter_ of the distribution. + * The expected mean of the distribution is equal to `Math.pow(lamba, -1)`. + * + * @param {number} lambda The rate parameter + * + * @return {number} A random number following an Exponential distribution + */ + RANDOM.nextExponential = function(lambda) { + if ('number' !== typeof lambda) { + throw new TypeError('nextExponential: lambda must be number.'); + } + if (lambda <= 0) { + throw new TypeError('nextExponential: ' + + 'lambda must be greater than 0.'); + } + return - Math.log(1 - Math.random()) / lambda; + }; + + /** + * ## RANDOM.nextBinomial + * + * Generates random numbers following the Binomial distribution. + * + * User must specify the probability of success and the number of trials. + * + * @param {number} p The probability of success + * @param {number} trials The number of trials + * + * @return {number} The sum of successes in n trials + */ + RANDOM.nextBinomial = function(p, trials) { + var counter, sum; + + if ('number' !== typeof p) { + throw new TypeError('nextBinomial: p must be number.'); + } + if ('number' !== typeof trials) { + throw new TypeError('nextBinomial: trials must be number.'); + } + if (p < 0 || p > 1) { + throw new TypeError('nextBinomial: p must between 0 and 1.'); + } + if (trials < 1) { + throw new TypeError('nextBinomial: trials must be greater than 0.'); + } + + counter = 0; + sum = 0; + + while(counter < trials){ + if (Math.random() < p) { + sum += 1; + } + counter++; + } + + return sum; + }; + + /** + * ## RANDOM.nextGamma + * + * Generates random numbers following the Gamma distribution. + * + * This function is experimental and untested. No documentation. + * + * @experimental + */ + RANDOM.nextGamma = function(alpha, k) { + var intK, kDiv, alphaDiv; + var u1, u2, u3; + var x, i, tmp; + + if ('number' !== typeof alpha) { + throw new TypeError('nextGamma: alpha must be number.'); + } + if ('number' !== typeof k) { + throw new TypeError('nextGamma: k must be number.'); + } + if (alpha < 1) { + throw new TypeError('nextGamma: alpha must be greater than 1.'); + } + if (k < 1) { + throw new TypeError('nextGamma: k must be greater than 1.'); + } + + u1 = Math.random(); + u2 = Math.random(); + u3 = Math.random(); + + intK = Math.floor(k) + 3; + kDiv = 1 / k; + + alphaDiv = 1 / alpha; + + x = 0; + for (i = 3 ; ++i < intK ; ) { + x += Math.log(Math.random()); + } + + x *= - alphaDiv; + + tmp = Math.log(u3) * + (Math.pow(u1, kDiv) / + ((Math.pow(u1, kDiv) + Math.pow(u2, 1 / (1 - k))))); + + tmp *= - alphaDiv; + + return x + tmp; + }; + + /** + * ### RANDOM.randomString + * + * Creates a parametric random string + * + * @param {number} len The length of string (must be > 0). Default, 6. + * @param {string} chars A code specifying which sets of characters + * to use. Available symbols (default 'a'): + * - 'a': lower case letters + * - 'A': upper case letters + * - '1': digits + * - '!': all remaining symbols (excluding spaces) + * - '_': spaces (it can be followed by an integer > 0 + * controlling the frequency of spaces, default = 1) + * @param {boolean} useChars If TRUE, the characters of the chars + * parameter are used as they are instead of interpreted as + * special symbols. Default FALSE. + * + * @return {string} result The random string + * + * Kudos to: http://stackoverflow.com/questions/10726909/ + * random-alpha-numeric-string-in-javascript + */ + RANDOM.randomString = function(len, chars, useChars) { + var mask, result, i, nSpaces; + if ('undefined' !== typeof len) { + if ('number' !== typeof len || len < 1) { + throw new Error('randomString: len must a number > 0 or ' + + 'undefined. Found: ' + len); + + } + } + if ('undefined' !== typeof chars) { + if ('string' !== typeof chars || chars.trim() === '') { + throw new Error('randomString: chars must a non-empty string ' + + 'or undefined. Found: ' + chars); + + } + } + else if (useChars) { + throw new Error('randomString: useChars is TRUE, but chars ' + + 'is undefined.'); + + } + + // Defaults. + len = len || 6; + chars = chars || 'a'; + + // Create/use mask from chars. + mask = ''; + if (!useChars) { + if (chars.indexOf('a') > -1) mask += 'abcdefghijklmnopqrstuvwxyz'; + if (chars.indexOf('A') > -1) mask += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (chars.indexOf('1') > -1) mask += '0123456789'; + if (chars.indexOf('!') > -1) { + mask += '!~`@#$%^&*()_+-={}[]:";\'<>?,./|\\'; + } + // Check how many spaces we should add. + nSpaces = chars.indexOf('_'); + if (nSpaces > -1) { + nSpaces = chars.charAt(nSpaces + 1); + // nSpaces is integer > 0 or 1. + nSpaces = JSUS.isInt(nSpaces, 0) || 1; + if (nSpaces === 1) mask += ' '; + else if (nSpaces === 2) mask += ' '; + else if (nSpaces === 3) mask += ' '; + else { + i = -1; + for ( ; ++i < nSpaces ; ) { + mask += ' '; + } + } + } + } + else { + mask = chars; + } + + i = -1, result = ''; + for ( ; ++i < len ; ) { + result += mask[Math.floor(Math.random() * mask.length)]; + } + return result; + }; + + /** + * ### RANDOM.randomEmail + * + * Creates a random email address + * + * @TODO: add options. + * + * @return {string} result The random email + */ + RANDOM.randomEmail = function() { + return RANDOM.randomString(RANDOM.randomInt(5,15), '!Aa0') + '@' + + RANDOM.randomString(RANDOM.randomInt(3,10)) + '.' + + RANDOM.randomString(RANDOM.randomInt(2,3)); + }; + + JSUS.extend(RANDOM); + +})('undefined' !== typeof JSUS ? JSUS : module.parent.exports.JSUS); + +/** + * # TIME + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * Collection of static functions related to the generation, + * manipulation, and formatting of time strings in JavaScript + */ +(function (JSUS) { + + "use strict"; + + function TIME() {} + + function pad(number) { + return (number < 10) ? '0' + number : number; + } + + function _getTime(ms) { + var d, res; + d = new Date(); + res = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + + pad(d.getSeconds()); + if (ms) res += ':' + pad(d.getMilliseconds()); + return res; + } + + // Polyfill for Date.toISOString (IE7, IE8, IE9) + // Kudos: https://developer.mozilla.org/en-US/docs/Web/ + // JavaScript/Reference/Global_Objects/Date/toISOString + if (!Date.prototype.toISOString) { + + Date.prototype.toISOString = function() { + var ms = (this.getUTCMilliseconds() / 1000).toFixed(3); + return this.getUTCFullYear() + + '-' + pad(this.getUTCMonth() + 1) + + '-' + pad(this.getUTCDate()) + + 'T' + pad(this.getUTCHours()) + + ':' + pad(this.getUTCMinutes()) + + ':' + pad(this.getUTCSeconds()) + + '.' + ms.slice(2, 5) + 'Z'; + }; + } + + /** + * ## TIME.getDate + * + * Returns a string representation of the current date and time (ISO) + * + * String is formatted as follows: + * + * YYYY-MM-DDTHH:mm:ss.sssZ + * + * @return {string} Formatted time string YYYY-MM-DDTHH:mm:ss.sssZ + */ + TIME.getDate = TIME.getFullDate = function() { + return new Date().toISOString(); + }; + + /** + * ## TIME.getTime + * + * Returns a string representation of the current time + * + * String is ormatted as follows: + * + * hh:mm:ss + * + * @return {string} Formatted time string hh:mm:ss + * + * @see TIME.getTimeM + */ + TIME.getTime = function() { + return _getTime(); + }; + + /** + * ## TIME.getTimeM + * + * Like TIME.getTime, but with millisecondsx + * + * String is ormatted as follows: + * + * hh:mm:ss:mls + * + * @return {string} Formatted time string hh:mm:ss:mls + * + * @see TIME.getTime + */ + TIME.getTimeM = function() { + return _getTime(true); + }; + + /** + * ## TIME.parseMilliseconds + * + * Parses milliseconds into an array of days, hours, minutes and seconds + * + * @param {number} ms Integer representing milliseconds + * + * @return {array} Milleconds parsed in days, hours, minutes, and seconds + */ + TIME.parseMilliseconds = function(ms) { + var result, x, seconds, minutes, hours, days; + if ('number' !== typeof ms) { + throw new TypeError('TIME.parseMilliseconds: ms must be number.'); + } + result = []; + x = ms / 1000; + result[4] = x; + seconds = x % 60; + result[3] = Math.floor(seconds); + x = x / 60; + minutes = x % 60; + result[2] = Math.floor(minutes); + x = x / 60; + hours = x % 24; + result[1] = Math.floor(hours); + x = x / 24; + days = x; + result[1] = Math.floor(days); + return result; + }; + + /** + * ## TIME.now + * + * Shortcut to Date.now (when existing), or its polyfill + * + * @return {number} The timestamp now + */ + TIME.now = 'function' === typeof Date.now ? + Date.now : function() { return new Date().getTime(); } + + JSUS.extend(TIME); + +})('undefined' !== typeof JSUS ? JSUS : module.parent.exports.JSUS); + +/** + * # PARSE + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * Collection of static functions related to parsing strings + */ +(function(JSUS) { + + "use strict"; + + function PARSE() {} + + /** + * ## PARSE.stringify_prefix + * + * Prefix used by PARSE.stringify and PARSE.parse + * to decode strings with special meaning + * + * @see PARSE.stringify + * @see PARSE.parse + */ + PARSE.stringify_prefix = '!?_'; + + PARSE.marker_func = PARSE.stringify_prefix + 'function'; + PARSE.marker_null = PARSE.stringify_prefix + 'null'; + PARSE.marker_und = PARSE.stringify_prefix + 'undefined'; + PARSE.marker_nan = PARSE.stringify_prefix + 'NaN'; + PARSE.marker_inf = PARSE.stringify_prefix + 'Infinity'; + PARSE.marker_minus_inf = PARSE.stringify_prefix + '-Infinity'; + + /** + * ## PARSE.getQueryString + * + * Parses current querystring and returns the requested variable. + * + * If no variable name is specified, returns the full query string. + * If requested variable is not found returns false. + * + * @param {string} name Optional. If set, returns only the value + * associated with this variable + * @param {string} referer Optional. If set, searches this string + * + * @return {string|boolean} The querystring, or a part of it, or FALSE + * + * Kudos: + * @see http://stackoverflow.com/q/901115/3347292 + */ + PARSE.getQueryString = function(name, referer) { + var regex, results; + if (referer && 'string' !== typeof referer) { + throw new TypeError('JSUS.getQueryString: referer must be string ' + + 'or undefined. Found: ' + referer); + } + referer = referer || window.location.search; + if ('undefined' === typeof name) return referer; + name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); + regex = new RegExp("[\\?&]" + name + "=([^&#]*)"); + results = regex.exec(referer); + return results === null ? false : + decodeURIComponent(results[1].replace(/\+/g, " ")); + }; + + /** + * ## PARSE.isMobileAgent + * + * Returns TRUE if a user agent is for a mobile device + * + * @param {string} agent Optional. The user agent to check. Default: + * navigator.userAgent + * + * @return {boolean} TRUE if a user agent is for a mobile device + */ + PARSE.isMobileAgent = function(agent) { + var rx; + if (!agent){ + if (!navigator) { + throw new Error('JSUS.isMobileAgent: agent undefined and ' + + 'no navigator. Are you in the browser?'); + } + agent = navigator.userAgent; + } + else if ('string' !== typeof agent) { + throw new TypeError('JSUS.isMobileAgent: agent must be undefined ' + + 'or string. Found: ' + agent); + } + rx = new RegExp('Android|webOS|iPhone|iPad|BlackBerry|' + + 'Windows Phone|Opera Mini|IEMobile|Mobile', 'i'); + + return rx.test(agent); + }; + + /** + * ## PARSE.tokenize + * + * Splits a string in tokens that users can specified as input parameter. + * Additional options can be specified with the modifiers parameter + * + * - limit: An integer that specifies the number of split items + * after the split limit will not be included in the array + * + * @param {string} str The string to split + * @param {array} separators Array containing the separators words + * @param {object} modifiers Optional. Configuration options + * for the tokenizing + * + * @return {array} Tokens in which the string was split + */ + PARSE.tokenize = function(str, separators, modifiers) { + var pattern, regex; + if (!str) return; + if (!separators || !separators.length) return [str]; + modifiers = modifiers || {}; + + pattern = '['; + + JSUS.each(separators, function(s) { + if (s === ' ') s = '\\s'; + + pattern += s; + }); + + pattern += ']+'; + + regex = new RegExp(pattern); + return str.split(regex, modifiers.limit); + }; + + /** + * ## PARSE.stringify + * + * Stringifies objects, functions, primitive, undefined or null values + * + * Makes uses `JSON.stringify` with a special reviver function, that + * strinfifies also functions, undefined, and null values. + * + * A special prefix is prepended to avoid name collisions. + * + * @param {mixed} o The value to stringify + * @param {number} spaces Optional the number of indentation spaces. + * Defaults, 0 + * + * @return {string} The stringified result + * + * @see JSON.stringify + * @see PARSE.stringify_prefix + */ + PARSE.stringify = function(o, spaces) { + return JSON.stringify(o, function(key, value) { + var type = typeof value; + if ('function' === type) { + return PARSE.stringify_prefix + value.toString(); + } + + if ('undefined' === type) return PARSE.marker_und; + if (value === null) return PARSE.marker_null; + if ('number' === type && isNaN(value)) return PARSE.marker_nan; + if (value === Number.POSITIVE_INFINITY) return PARSE.marker_inf; + if (value === Number.NEGATIVE_INFINITY) { + return PARSE.marker_minus_inf; + } + + return value; + + }, spaces); + }; + + /** + * ## PARSE.stringifyAll + * + * Copies all the properties of the prototype before stringifying + * + * Notice: The original object is modified! + * + * @param {mixed} o The value to stringify + * @param {number} spaces Optional the number of indentation spaces. + * Defaults, 0 + * + * @return {string} The stringified result + * + * @see PARSE.stringify + */ + PARSE.stringifyAll = function(o, spaces) { + var i; + if ('object' === typeof o) { + for (i in o) { + if (!o.hasOwnProperty(i)) { + if ('object' === typeof o[i]) { + o[i] = PARSE.stringifyAll(o[i]); + } + else { + o[i] = o[i]; + } + } + } + } + return PARSE.stringify(o); + }; + + /** + * ## PARSE.parse + * + * Decodes strings in objects and other values + * + * Uses `JSON.parse` and then looks for special strings + * encoded by `PARSE.stringify` + * + * @param {string} str The string to decode + * @param {function} cb Optional. A callback to apply to each decoded item + * + * @return {mixed} The decoded value + * + * @see JSON.parse + * @see PARSE.stringify_prefix + */ + PARSE.parse = (function() { + + var len_prefix = PARSE.stringify_prefix.length, + len_func = PARSE.marker_func.length, + len_null = PARSE.marker_null.length, + len_und = PARSE.marker_und.length, + len_nan = PARSE.marker_nan.length, + len_inf = PARSE.marker_inf.length, + len_minus_inf = PARSE.marker_minus_inf.length; + + var customCb; + + function walker(o) { + var i; + if ('object' !== typeof o) return reviver(o); + for (i in o) { + if (o.hasOwnProperty(i)) { + if ('object' === typeof o[i]) walker(o[i]); + else o[i] = reviver(o[i]); + } + } + // On the full object. + if (customCb) customCb(o); + return o; + } + + function reviver(value) { + var type; + type = typeof value; + + if (type === 'string') { + if (value.substring(0, len_prefix) !== PARSE.stringify_prefix) { + return value; + } + else if (value.substring(0, len_func) === PARSE.marker_func) { + return JSUS.eval(value.substring(len_prefix)); + } + else if (value.substring(0, len_null) === PARSE.marker_null) { + return null; + } + else if (value.substring(0, len_und) === PARSE.marker_und) { + return undefined; + } + + else if (value.substring(0, len_nan) === PARSE.marker_nan) { + return NaN; + } + else if (value.substring(0, len_inf) === PARSE.marker_inf) { + return Infinity; + } + else if (value.substring(0, len_minus_inf) === + PARSE.marker_minus_inf) { + + return -Infinity; + } + } + + return value; + } + + return function(str, cb) { + customCb = cb; + return walker(JSON.parse(str)); + }; + + })(); + + /** + * ## PARSE.isInt + * + * Checks if a value is an integer number or a string containing one + * + * Non-numbers, Infinity, NaN, and floats will return FALSE + * + * @param {mixed} n The value to check + * @param {number} lower Optional. If set, n must be greater than lower + * @param {number} upper Optional. If set, n must be smaller than upper + * @param {boolean} leq Optional. If TRUE, n can also be equal to lower + * @param {boolean} ueq Optional. If TRUE, n can also be equal to upper + * + * @return {boolean|number} The parsed integer, or FALSE if none was found + * + * @see PARSE.isFloat + * @see PARSE.isNumber + */ + PARSE.isInt = function(n, lower, upper, leq, ueq) { + var regex, i; + regex = /^-?\d+$/; + if (!regex.test(n)) return false; + i = parseInt(n, 10); + if (i !== parseFloat(n)) return false; + return PARSE.isNumber(i, lower, upper, leq, ueq); + }; + + /** + * ## PARSE.isFloat + * + * Checks if a value is a float number or a string containing one + * + * Non-numbers, Infinity, NaN, and integers will return FALSE + * + * @param {mixed} n The value to check + * @param {number} lower Optional. If set, n must be greater than lower + * @param {number} upper Optional. If set, n must be smaller than upper + * @param {boolean} leq Optional. If TRUE, n can also be equal to lower + * @param {boolean} ueq Optional. If TRUE, n can also be equal to upper + * + * @return {boolean|number} The parsed float, or FALSE if none was found + * + * @see PARSE.isInt + * @see PARSE.isNumber + */ + PARSE.isFloat = function(n, lower, upper, leq, ueq) { + var regex; + regex = /^-?\d*(\.\d+)?$/; + if (!regex.test(n)) return false; + if (n.toString().indexOf('.') === -1) return false; + return PARSE.isNumber(n, lower, upper, leq, ueq); + }; + + /** + * ## PARSE.isNumber + * + * Checks if a value is a number (int or float) or a string containing one + * + * Non-numbers, Infinity, NaN will return FALSE + * + * @param {mixed} n The value to check + * @param {number} lower Optional. If set, n must be greater than lower + * @param {number} upper Optional. If set, n must be smaller than upper + * @param {boolean} leq Optional. If TRUE, n can also be equal to lower + * @param {boolean} ueq Optional. If TRUE, n can also be equal to upper + * + * @return {boolean|number} The parsed number, or FALSE if none was found + * + * @see PARSE.isInt + * @see PARSE.isFloat + */ + PARSE.isNumber = function(n, lower, upper, leq, ueq) { + if (isNaN(n) || !isFinite(n) || n === "") return false; + n = parseFloat(n); + if ('number' === typeof lower && (leq ? n < lower : n <= lower)) { + return false; + } + if ('number' === typeof upper && (ueq ? n > upper : n >= upper)) { + return false; + } + return n; + }; + + /** + * ## PARSE.isEmail + * + * Returns TRUE if the email's format is valid + * + * @param {string} The email to check + * + * @return {boolean} TRUE, if the email format is valid + */ + PARSE.isEmail = function(email) { + var idx; + if ('string' !== typeof email) return false; + if (email.trim().length < 5) return false; + idx = email.indexOf('@'); + if (idx === -1 || idx === 0 || idx === (email.length-1)) return false; + idx = email.lastIndexOf('.'); + if (idx === -1 || idx === (email.length-1) || idx > (idx+1)) { + return false; + } + return true; + }; + + /** + * ## PARSE.range + * + * Decodes semantic strings into an array of integers + * + * Let n, m and l be integers, then the tokens of the string are + * interpreted in the following way: + * + * - `*`: Any integer + * - `n`: The integer `n` + * - `begin`: The smallest integer in `available` + * - `end`: The largest integer in `available` + * - `n`, `>=n`: Any integer (strictly) smaller/larger than n + * - `n..m`, `[n,m]`: Any integer between n and m (both inclusively) + * - `n..l..m`: Any i + * - `[n,m)`: Any integer between n (inclusively) and m (exclusively) + * - `(n,m]`: Any integer between n (exclusively) and m (inclusively) + * - `(n,m)`: Any integer between n and m (both exclusively) + * - `%n`: Divisible by n + * - `%n = m`: Divisible with rest m + * - `!`: Logical not + * - `|`, `||`, `,`: Logical or + * - `&`, `&&`: Logical and + * + * The elements of the resulting array are all elements of the `available` + * array which satisfy the expression defined by `expr`. + * + * Examples: + * + * PARSE.range('2..5, >8 & !11', '[-2,12]'); // [2,3,4,5,9,10,12] + * + * PARSE.range('begin...end/2 | 3*end/4...3...end', '[0,40) & %2 = 1'); + * // [1,3,5,7,9,11,13,15,17,19,29,35] (end == 39) + * + * PARSE.range('<=19, 22, %5', '>6 & !>27'); + * // [7,8,9,10,11,12,13,14,15,16,17,18,19,20,22,25] + * + * PARSE.range('*','(3,8) & !%4, 22, (10,12]'); // [5,6,7,11,12,22] + * + * PARSE.range('<4', { + * begin: 0, + * end: 21, + * prev: 0, + * cur: 1, + * next: function() { + * var temp = this.prev; + * this.prev = this.cur; + * this.cur += temp; + * return this.cur; + * }, + * isFinished: function() { + * return this.cur + this.prev > this.end; + * } + * }); // [5, 8, 13, 21] + * + * @param {string|number} expr The selection expression + * @param {mixed} available Optional. If undefined `expr` is used. If: + * - string: it is interpreted according to the same rules as `expr`; + * - array: it is used as it is; + * - object: provide functions next, isFinished and attributes begin, end + * + * @return {array} The array containing the specified values + * + * @see JSUS.eval + */ + PARSE.range = function(expr, available) { + var i,len, x; + var solution; + var begin, end, lowerBound, numbers; + var invalidChars, invalidBeforeOpeningBracket, invalidDot; + + solution = []; + if ('undefined' === typeof expr) return solution; + + // TODO: this could be improved, i.e. if it is a number, many + // checks and regular expressions could be avoided. + if ('number' === typeof expr) expr = '' + expr; + else if ('string' !== typeof expr) { + throw new TypeError('PARSE.range: expr must be string, number, ' + + 'undefined. Found: ' + expr); + } + // If no available numbers defined, assumes all possible are allowed. + if ('undefined' === typeof available) { + available = expr; + } + else if (JSUS.isArray(available)) { + if (available.length === 0) return solution; + begin = Math.min.apply(null, available); + end = Math.max.apply(null, available); + } + else if ('object' === typeof available) { + if ('function' !== typeof available.next) { + throw new TypeError('PARSE.range: available.next must be ' + + 'function. Found: ' + available.next); + } + if ('function' !== typeof available.isFinished) { + throw new TypeError('PARSE.range: available.isFinished must ' + + 'be function. Found: ' + + available.isFinished); + } + if ('number' !== typeof available.begin) { + throw new TypeError('PARSE.range: available.begin must be ' + + 'number. Found: ' + available.begin); + } + if ('number' !== typeof available.end) { + throw new TypeError('PARSE.range: available.end must be ' + + 'number. Found: ' + available.end); + } + + begin = available.begin; + end = available.end; + } + else if ('string' === typeof available) { + // If the availble points are also only given implicitly, + // compute set of available numbers by first guessing a bound. + available = preprocessRange(available); + + numbers = available.match(/([-+]?\d+)/g); + if (numbers === null) { + throw new Error('PARSE.range: no numbers in available: ' + + available); + } + lowerBound = Math.min.apply(null, numbers); + + available = PARSE.range(available, { + begin: lowerBound, + end: Math.max.apply(null, numbers), + value: lowerBound, + next: function() { + return this.value++; + }, + isFinished: function() { + return this.value > this.end; + } + }); + begin = Math.min.apply(null, available); + end = Math.max.apply(null, available); + } + else { + throw new TypeError('PARSE.range: available must be string, ' + + 'array, object or undefined. Found: ' + + available); + } + + // end -> maximal available value. + expr = expr.replace(/end/g, parseInt(end, 10)); + + // begin -> minimal available value. + expr = expr.replace(/begin/g, parseInt(begin, 10)); + + // Do all computations. + expr = preprocessRange(expr); + + // Round all floats + expr = expr.replace(/([-+]?\d+\.\d+)/g, function(match, p1) { + return parseInt(p1, 10); + }); + + // Validate expression to only contain allowed symbols. + invalidChars = /[^ \*\d<>=!\|&\.\[\],\(\)\-\+%]/g; + if (expr.match(invalidChars)) { + throw new Error('PARSE.range: invalid characters found: ' + expr); + } + + // & -> && and | -> ||. + expr = expr.replace(/([^& ]) *& *([^& ])/g, "$1&&$2"); + expr = expr.replace(/([^| ]) *\| *([^| ])/g, "$1||$2"); + + // n -> (x == n). + expr = expr.replace(/([-+]?\d+)/g, "(x==$1)"); + + // n has already been replaced by (x==n) so match for that from now on. + + // %n -> !(x%n) + expr = expr.replace(/% *\(x==([-+]?\d+)\)/,"!(x%$1)"); + + // %n has already been replaced by !(x%n) so match for that from now on. + // %n = m, %n == m -> (x%n == m). + expr = expr.replace(/!\(x%([-+]?\d+)\) *={1,} *\(x==([-+]?\d+)\)/g, + "(x%$1==$2)"); + + // n, >=n -> (x < n), (x <= n), (x > n), (x >= n) + expr = expr.replace(/([<>]=?) *\(x==([-+]?\d+)\)/g, "(x$1$2)"); + + // n..l..m -> (x >= n && x <= m && !((x-n)%l)) for positive l. + expr = expr.replace( + /\(x==([-+]?\d+)\)\.{2,}\(x==(\+?\d+)\)\.{2,}\(x==([-+]?\d+)\)/g, + "(x>=$1&&x<=$3&&!((x- $1)%$2))"); + + // n..l..m -> (x <= n && x >= m && !((x-n)%l)) for negative l. + expr = expr.replace( + /\(x==([-+]?\d+)\)\.{2,}\(x==(-\d+)\)\.{2,}\(x==([-+]?\d+)\)/g, + "(x<=$1&&x>=$3&&!((x- $1)%$2))"); + + // n..m -> (x >= n && x <= m). + expr = expr.replace(/\(x==([-+]?\d+)\)\.{2,}\(x==([-+]?\d+)\)/g, + "(x>=$1&&x<=$2)"); + + // (n,m), ... ,[n,m] -> (x > n && x < m), ... , (x >= n && x <= m). + expr = expr.replace( + /([(\[]) *\(x==([-+]?\d+)\) *, *\(x==([-+]?\d+)\) *([\])])/g, + function (match, p1, p2, p3, p4) { + return "(x>" + (p1 == '(' ? '': '=') + p2 + "&&x<" + + (p4 == ')' ? '' : '=') + p3 + ')'; + } + ); + + // * -> true. + expr = expr.replace('*', 1); + + // Remove spaces. + expr = expr.replace(/\s/g, ''); + + // a, b -> (a) || (b) + expr = expr.replace(/\)[,] *(!*)\(/g, ")||$1("); + + // Validating the expression before eval"ing it. + invalidChars = /[^ \d<>=!\|&,\(\)\-\+%x\.]/g; + // Only & | ! may be before an opening bracket. + invalidBeforeOpeningBracket = /[^ &!|\(] *\(/g; + // Only dot in floats. + invalidDot = /\.[^\d]|[^\d]\./; + + if (expr.match(invalidChars)) { + throw new Error('PARSE.range: invalid characters found: ' + expr); + } + if (expr.match(invalidBeforeOpeningBracket)) { + throw new Error('PARSE.range: invalid character before opending ' + + 'bracket found: ' + expr); + } + if (expr.match(invalidDot)) { + throw new Error('PARSE.range: invalid dot found: ' + expr); + } + + if (JSUS.isArray(available)) { + i = -1, len = available.length; + for ( ; ++i < len ; ) { + x = parseInt(available[i], 10); + if (JSUS.eval(expr.replace(/x/g, x))) { + solution.push(x); + } + } + } + else { + while (!available.isFinished()) { + x = parseInt(available.next(), 10); + if (JSUS.eval(expr.replace(/x/g, x))) { + solution.push(x); + } + } + } + return solution; + }; + + function preprocessRange(expr) { + var mult = function(match, p1, p2, p3) { + var n1 = parseInt(p1, 10); + var n3 = parseInt(p3, 10); + return p2 == '*' ? n1*n3 : n1/n3; + }; + var add = function(match, p1, p2, p3) { + var n1 = parseInt(p1, 10); + var n3 = parseInt(p3, 10); + return p2 == '-' ? n1 - n3 : n1 + n3; + }; + var mod = function(match, p1, p2, p3) { + var n1 = parseInt(p1, 10); + var n3 = parseInt(p3, 10); + return n1 % n3; + }; + + while (expr.match(/([-+]?\d+) *([*\/]) *([-+]?\d+)/g)) { + expr = expr.replace(/([-+]?\d+) *([*\/]) *([-+]?\d+)/, mult); + } + + while (expr.match(/([-+]?\d+) *([-+]) *([-+]?\d+)/g)) { + expr = expr.replace(/([-+]?\d+) *([-+]) *([-+]?\d+)/, add); + } + while (expr.match(/([-+]?\d+) *% *([-+]?\d+)/g)) { + expr = expr.replace(/([-+]?\d+) *% *([-+]?\d+)/, mod); + } + return expr; + } + + /** + * ## PARSE.funcName + * + * Returns the name of the function + * + * Function.name is a non-standard JavaScript property, + * although many browsers implement it. This is a cross-browser + * implementation for it. + * + * In case of anonymous functions, an empty string is returned. + * + * @param {function} func The function to check + * + * @return {string} The name of the function + * + * Kudos to: + * http://matt.scharley.me/2012/03/09/monkey-patch-name-ie.html + */ + if ('undefined' !== typeof Function.prototype.name) { + PARSE.funcName = function(func) { + if ('function' !== typeof func) { + throw new TypeError('PARSE.funcName: func must be function. ' + + 'Found: ' + func); + } + return func.name; + }; + } + else { + PARSE.funcName = function(func) { + var funcNameRegex, res; + if ('function' !== typeof func) { + throw new TypeError('PARSE.funcName: func must be function. ' + + 'Found: ' + func); + } + funcNameRegex = /function\s([^(]{1,})\(/; + res = (funcNameRegex).exec(func.toString()); + return (res && res.length > 1) ? res[1].trim() : ""; + }; + } + + JSUS.extend(PARSE); + +})('undefined' !== typeof JSUS ? JSUS : module.parent.exports.JSUS); + +/** + * # NDDB: N-Dimensional Database + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * NDDB is a powerful and versatile object database for node.js and the browser. + * --- + */ +(function(J) { + + "use strict"; + + if ('undefined' !== typeof module && + 'undefined' !== typeof module.exports) { + + J = module.parent.exports.JSUS || require('JSUS').JSUS; + module.exports = NDDB; + // Backward compatibility. + module.exports.NDDB = NDDB; + } + else { + J = JSUS; + window.NDDB = NDDB; + } + + if (!J) throw new Error('NDDB: missing dependency: JSUS.'); + + /** + * ### df + * + * Flag indicating support for method Object.defineProperty + * + * If support is missing, the index `_nddbid` will be as a normal + * property, and, therefore, it will be enumerable. + * + * @see nddb_insert + * JSUS.compatibility + */ + var df = J.compatibility().defineProperty; + + /** + * ### NDDB.db + * + * Returns a new db + * + * @param {object} options Optional. Configuration options + * @param {db} db Optional. An initial set of items to import + * + * @return {object} A new database + */ + NDDB.db = function(opts, db) { return new NDDB(opts, db); }; + + // Might get overwritten in index.js. + NDDB.lineBreak = '\n'; + + + /** + * ### NDDB.decycle + * + * Removes cyclic references from an object + * + * @param {object} e The object to decycle + * + * @return {object} e The decycled object + * + * @see https://github.com/douglascrockford/JSON-js/ + */ + NDDB.decycle = function(e) { + if (JSON && 'function' === typeof JSON.decycle) { + e = JSON.decycle(e); + } + return e; + }; + + /** + * ### NDDB.retrocycle + * + * Restores cyclic references in an object previously decycled + * + * @param {object} e The object to retrocycle + * + * @return {object} e The retrocycled object + * + * @see https://github.com/douglascrockford/JSON-js/ + */ + NDDB.retrocycle = function(e) { + if (JSON && 'function' === typeof JSON.retrocycle) { + e = JSON.retrocycle(e); + } + return e; + }; + + /** + * ## NDDB constructor + * + * Creates a new instance of NDDB + * + * @param {object} options Optional. Configuration options + * @param {db} db Optional. An initial set of items to import + */ + function NDDB(opts, db) { + var that; + that = this; + opts = opts || {}; + + // ## Public properties. + + this.name = opts.name || 'nddb'; + + // ### nddbid + // A global index of all objects. + this.nddbid = new NDDBIndex('nddbid', this); + + // ### db + // The default database. + this.db = []; + + // ### lastSelection + // The subset of items that were selected during the last operation + // Notice: some of the items might not exist any more in the database. + // @see NDDB.fetch + this.lastSelection = []; + + // ### nddbid + // A global index of all hashed objects + // @see NDDBHashtray + this.hashtray = new NDDBHashtray(); + + // ###tags + // The tags list. + this.tags = {}; + + // ### hooks + // The list of hooks and associated callbacks + this.hooks = { + insert: [], + remove: [], + update: [], + setwd: [], + save: [], + load: [] + }; + + // ### sharedHooks + // The list of hooks and associated callbacks shared with child database + // @experimental + this.sharedHooks = { + insert: [], + remove: [], + update: [], + setwd: [], + save: [], + load: [] + }; + + // ### nddb_pointer + // Pointer for iterating along all the elements + this.nddb_pointer = 0; + + // ### query + // QueryBuilder obj + // @see QueryBuilder + this.query = new QueryBuilder(); + + // ### filters + // Available db filters + this.filters = {}; + this.addDefaultFilters(); + + // ### __userDefinedFilters + // Filters that are defined with addFilter + // The field is needed by cloneSettings + // @see NDDB.addFilter + this.__userDefinedFilters = {}; + + // ### __C + // List of comparator functions + this.__C = {}; + + // ### __H + // List of hash functions + this.__H = {}; + + // ### __I + // List of index functions + this.__I = {}; + + // ### __I + // List of view functions + this.__V = {}; + + // ### __update + // Auto update options container + this.__update = {}; + + // ### __update.pointer + // If TRUE, nddb_pointer always points to the last insert + this.__update.pointer = false; + + // ### __update.indexes + // If TRUE, rebuild indexes on every insert and remove + this.__update.indexes = false; + + // ### __update.sort + // If TRUE, sort db on every insert and remove + this.__update.sort = false; + + // ### __shared + // Objects shared (not cloned) among breeded NDDB instances + this.__shared = {}; + + // ### __formats + // Currently supported formats for saving/loading items. + this.__formats = {}; + + // ### __defaultFormat + // Default format for saving and loading items. + this.__defaultFormat = null; + + // ### __wd + // Default working directory for saving and loading files. + this.__wd = null; + + // ### __parentDb + // The parent NDDB instance from which this db was created. + // Set in views and hashes. + // @experimental + this.__parentDb = null; + + // ### log + // Std out for log messages + // + // It can be overriden in options by another function (`opts.log`). + // `opts.logCtx` specif the context of execution. + // @see NDDB.initLog + this.log = console.log; + + // ### globalCompare + // Dummy compare function used to sort elements in the database + // + // It can be overriden with a compare function returning: + // + // - 0 if the objects are the same + // - a positive number if o2 precedes o1 + // - a negative number if o1 precedes o2 + // + this.globalCompare = function(o1, o2) { + return -1; + }; + + // Adding the "compareInAllFields" function. + // + // @see NDDB.comparator + this.comparator('*', function(o1, o2, trigger1, trigger2) { + var d, c, res; + for (d in o1) { + c = that.getComparator(d); + o2[d] = o2['*']; + res = c(o1, o2); + if (res === trigger1) return res; + if ('undefined' !== trigger2 && res === trigger2) return res; + // No need to delete o2[d] afer comparison. + } + + // We are not interested in sorting. + // Figuring out the right return value. + if (trigger1 === 0) { + return trigger2 === 1 ? -1 : 1; + } + if (trigger1 === 1) { + return trigger2 === 0 ? -1 : 0; + } + + return trigger2 === 0 ? 1 : 0; + }); + + // Add default formats (e.g. CSV, JSON in Node.js). + // See `/lib/fs.js`. + if ('function' === typeof this.addDefaultFormats) { + this.addDefaultFormats(); + } + + // Stores information about files saved (e.g., headers). + // Keys are filenames. There is one centeral cache for + // all hashes and views. + // @experimental. + this.__cache = {}; + + // Mixing in user options and defaults. + this.init(opts); + + // Importing items, if any. + if (db) this.importDB(db); + + if (opts.journal && 'function' === typeof NDDB.prototype.journal) { + this.journal({ filename: opts.journal, load: true, cb: opts.cb }); + } + } + + /** + * ### NDDB.addFilter + * + * Registers a _select_ function under an alphanumeric id + * + * When calling `NDDB.select('d','OP','value')` the second parameter (_OP_) + * will be matched with the callback function specified here. + * + * Callback function must accept three input parameters: + * + * - d: dimension of comparison + * - value: second-term of comparison + * - comparator: the comparator function as defined by `NDDB.comparator` + * + * and return a function that execute the desired operation. + * + * Registering a new filter with the same name of an already existing + * one, will overwrite the old filter without warnings. + * + * A reference to newly added filters are registered under + * `__userDefinedFilter`, so that they can be copied by `cloneSettings`. + * + * @param {string} op An alphanumeric id + * @param {function} cb The callback function + * + * @see QueryBuilder.addDefaultOperators + */ + NDDB.prototype.addFilter = function(op, cb) { + this.filters[op] = cb; + this.__userDefinedFilters[op] = this.filters[op]; + }; + + /** + * ### NDDB.addDefaultFilters + * + * Register default filters for NDDB + * + * Default filters include standard logical operators: + * + * - '=', '==', '!=', ''>', >=', '<', '<=', + * + * and: + * + * - 'E': field exists (can be omitted, it is the default one) + * - '><': between values + * - '<>': not between values + * - 'in': element is found in array + * - '!in': element is noi found in array + * - 'LIKE': string SQL LIKE (case sensitive) + * - 'iLIKE': string SQL LIKE (case insensitive) + * + * @see NDDB.filters + */ + NDDB.prototype.addDefaultFilters = function() { + var that; + that = this; + + // Exists. + this.filters['E'] = function(d, value, comparator) { + if ('object' === typeof d) { + return function(elem) { + var d, c; + for (d in elem) { + c = that.getComparator(d); + value[d] = value[0]['*']; + if (c(elem, value, 1) > 0) { + value[d] = value[1]['*']; + if (c(elem, value, -1) < 0) { + return elem; + } + } + } + if ('undefined' !== typeof elem[d]) { + return elem; + } + else if ('undefined' !== typeof J.getNestedValue(d,elem)) { + return elem; + } + }; + } + else { + return function(elem) { + if ('undefined' !== typeof elem[d]) { + return elem; + } + else if ('undefined' !== typeof J.getNestedValue(d,elem)) { + return elem; + } + }; + } + }; + + // (strict) Equals. + this.filters['=='] = function(d, value, comparator) { + return function(elem) { + if (comparator(elem, value, 0) === 0) return elem; + }; + }; + + // (strict) Not Equals. + this.filters['!='] = function(d, value, comparator) { + return function(elem) { + if (comparator(elem, value, 0) !== 0) return elem; + }; + }; + + // Smaller than. + this.filters['>'] = function(d, value, comparator) { + if ('object' === typeof d || d === '*') { + return function(elem) { + if (comparator(elem, value, 1) === 1) return elem; + }; + } + else { + return function(elem) { + if ('undefined' === typeof elem[d]) return; + if (comparator(elem, value, 1) === 1) return elem; + }; + } + }; + + // Greater than. + this.filters['>='] = function(d, value, comparator) { + if ('object' === typeof d || d === '*') { + return function(elem) { + var compared = comparator(elem, value, 0, 1); + if (compared === 1 || compared === 0) return elem; + }; + } + else { + return function(elem) { + if ('undefined' === typeof elem[d]) return; + var compared = comparator(elem, value, 0, 1); + if (compared === 1 || compared === 0) return elem; + }; + } + }; + + // Smaller than. + this.filters['<'] = function(d, value, comparator) { + if ('object' === typeof d || d === '*') { + return function(elem) { + if (comparator(elem, value, -1) === -1) return elem; + }; + } + else { + return function(elem) { + if ('undefined' === typeof elem[d]) return; + if (comparator(elem, value, -1) === -1) return elem; + }; + } + }; + + // Smaller or equal than. + this.filters['<='] = function(d, value, comparator) { + if ('object' === typeof d || d === '*') { + return function(elem) { + var compared = comparator(elem, value, 0, -1); + if (compared === -1 || compared === 0) return elem; + }; + } + else { + return function(elem) { + if ('undefined' === typeof elem[d]) return; + var compared = comparator(elem, value, 0, -1); + if (compared === -1 || compared === 0) return elem; + }; + } + }; + + // Between. + this.filters['><'] = function(d, value, comparator) { + if ('object' === typeof d) { + return function(elem) { + var i, len; + len = d.length; + for (i = 0; i < len ; i++) { + if (comparator(elem, value[0], 1) > 0 && + comparator(elem, value[1], -1) < 0) { + return elem; + } + } + }; + } + else if (d === '*') { + return function(elem) { + var d, c; + for (d in elem) { + c = that.getComparator(d); + value[d] = value[0]['*']; + if (c(elem, value, 1) > 0) { + value[d] = value[1]['*']; + if (c(elem, value, -1) < 0) { + return elem; + } + } + } + }; + } + else { + return function(elem) { + if (comparator(elem, value[0], 1) > 0 && + comparator(elem, value[1], -1) < 0) { + return elem; + } + }; + } + }; + + // Not Between. + this.filters['<>'] = function(d, value, comparator) { + if ('object' === typeof d || d === '*') { + return function(elem) { + if (comparator(elem, value[0], -1) < 0 || + comparator(elem, value[1], 1) > 0) { + return elem; + } + }; + } + else { + return function(elem) { + if ('undefined' === typeof elem[d]) return; + if (comparator(elem, value[0], -1) < 0 || + comparator(elem, value[1], 1) > 0) { + return elem; + } + }; + } + }; + + // In Array. + this.filters['in'] = function(d, value, comparator) { + if ('object' === typeof d) { + return function(elem) { + var i, len; + len = value.length; + for (i = 0; i < len; i++) { + if (comparator(elem, value[i], 0) === 0) { + return elem; + } + } + }; + } + else { + return function(elem) { + var i, obj, len; + obj = {}, len = value.length; + for (i = 0; i < len; i++) { + obj[d] = value[i]; + if (comparator(elem, obj, 0) === 0) { + return elem; + } + } + }; + } + }; + + // Not In Array. + this.filters['!in'] = function(d, value, comparator) { + if ('object' === typeof d) { + return function(elem) { + var i, len; + len = value.length; + for (i = 0; i < len; i++) { + if (comparator(elem, value[i], 0) === 0) { + return; + } + } + return elem; + }; + } + else { + return function(elem) { + var i, obj, len; + obj = {}, len = value.length; + for (i = 0; i < len; i++) { + obj[d] = value[i]; + if (comparator(elem, obj, 0) === 0) { + return; + } + } + return elem; + }; + } + }; + + // Supports `_` and `%` wildcards. + function generalLike(d, value, comparator, sensitive) { + var regex; + + RegExp.escape = function(str) { + return str.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'); + }; + + regex = RegExp.escape(value); + regex = regex.replace(/%/g, '.*').replace(/_/g, '.'); + regex = new RegExp('^' + regex + '$', sensitive); + + if ('object' === typeof d) { + return function(elem) { + var i, len; + len = d.length; + for (i = 0; i < len; i++) { + if ('undefined' !== typeof elem[d[i]]) { + if (regex.test(elem[d[i]])) { + return elem; + } + } + } + }; + } + else if (d === '*') { + return function(elem) { + var d; + for (d in elem) { + if ('undefined' !== typeof elem[d]) { + if (regex.test(elem[d])) { + return elem; + } + } + } + }; + } + else { + return function(elem) { + if ('undefined' !== typeof elem[d]) { + if (regex.test(elem[d])) { + return elem; + } + } + }; + } + } + + // Like operator (Case Sensitive). + this.filters['LIKE'] = function likeOperator(d, value, comparator) { + return generalLike(d, value, comparator); + }; + + // Like operator (Case Insensitive). + this.filters['iLIKE'] = function likeOperatorI(d, value, comparator) { + return generalLike(d, value, comparator, 'i'); + }; + + }; + + // ## METHODS + + /** + * ### NDDB.throwErr + * + * Throws an error with a predefined format + * + * The format is "constructor name" . "method name" : "error text" . + * + * It does **not** perform type checking on itw own input parameters. + * + * @param {string} type Optional. The error type, e.g. 'TypeError'. + * Default, 'Error' + * @param {string} method Optional. The name of the method + * @param {string|object} err Optional. The error. Default, 'generic error' + */ + NDDB.prototype.throwErr = function(type, method, err) { + var errMsg, text; + + if ('object' === typeof err) text = err.stack || err; + else if ('string' === typeof err) text = err; + + text = text || 'generic error'; + errMsg = this._getConstrName(); + if (method) errMsg = errMsg + '.' + method; + errMsg = errMsg + ': ' + text; + if (type === 'TypeError') throw new TypeError(errMsg); + throw new Error(errMsg); + }; + + /** + * ### NDDB.init + * + * Sets global options based on local configuration + * + * @param {object} options Optional. Configuration options + * + * TODO: type checking on input params + */ + NDDB.prototype.init = function(options) { + var filter, sh, i; + var errMsg; + options = options || {}; + + this.__options = options; + + if (options.tags) { + if ('object' !== typeof options.tags) { + errMsg = 'options.tag must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + this.tags = options.tags; + } + + if ('undefined' !== typeof options.nddb_pointer) { + if ('number' !== typeof options.nddb_pointer) { + errMsg = 'options.nddb_pointer must be number or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + this.nddb_pointer = options.nddb_pointer; + } + + if (options.hooks) { + if ('object' !== typeof options.hooks) { + errMsg = 'options.hooks must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + this.hooks = options.hooks; + } + + if (options.globalCompare) { + if ('function' !== typeof options.globalCompare) { + errMsg = 'options.globalCompare must be function or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + this.globalCompare = options.globalCompare; + } + + if (options.update) { + if ('object' !== typeof options.update) { + errMsg = 'options.update must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + if ('undefined' !== typeof options.update.pointer) { + this.__update.pointer = options.update.pointer; + } + + if ('undefined' !== typeof options.update.indexes) { + this.__update.indexes = options.update.indexes; + } + + if ('undefined' !== typeof options.update.sort) { + this.__update.sort = options.update.sort; + } + } + + if ('object' === typeof options.filters) { + if ('object' !== typeof options.filters) { + errMsg = 'options.filters must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + for (filter in options.filters) { + this.addFilter(filter, options.filters[filter]); + } + } + + if ('object' === typeof options.shared) { + for (sh in options.shared) { + if (options.shared.hasOwnProperty(sh)) { + this.__shared[sh] = options.shared[sh]; + } + } + } + // Delete the shared object, it must not be copied by _cloneSettings_. + delete this.__options.shared; + + if (options.log) { + this.initLog(options.log, options.logCtx); + } + + if (options.formats) { + if ('object' !== typeof options.formats) { + errMsg = 'options.formats must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + for (i in options.formats) { + if (options.formats.hasOwnProperty(i)) { + this.addFormat(i, options.formats[i]); + } + } + } + + if (options.defaultFormat) { + this.setDefaultFormat(options.defaultFormat); + } + + if (options.wd && 'function' === typeof this.setWD) { + this.setWD(options.wd); + } + + // Below there might modifications to the options + // object via the cloneSettings method. + + if (options.C) { + if ('object' !== typeof options.C) { + errMsg = 'options.C must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + this.__C = options.C; + } + + if (options.H) { + if ('object' !== typeof options.H) { + errMsg = 'options.H must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + for (i in options.H) { + if (options.H.hasOwnProperty(i)) { + this.hash(i, options.H[i]); + } + } + } + + if (options.I) { + if ('object' !== typeof options.I) { + errMsg = 'options.I must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + this.__I = options.I; + for (i in options.I) { + if (options.I.hasOwnProperty(i)) { + this.index(i, options.I[i]); + } + } + } + // Views must be created at the end because they are cloning + // all the previous settings (the method would also pollute + // this.__options if called before all options in init are set). + if (options.V) { + if ('object' !== typeof options.V) { + errMsg = 'options.V must be object or undefined'; + this.throwErr('TypeError', 'init', errMsg); + } + this.__V = options.V; + for (i in options.V) { + if (options.V.hasOwnProperty(i)) { + this.view(i, options.V[i]); + } + } + } + }; + + /** + * ### NDDB.initLog + * + * Setups and external log function to be executed in the proper context + * + * @param {function} cb The logging function + * @param {object} ctx Optional. The context of the log function + */ + NDDB.prototype.initLog = function(cb, ctx) { + if ('function' !== typeof cb) { + this.throwErr('TypeError', 'initLog', 'cb must be function'); + } + ctx = ctx || this; + if ('function' !== typeof ctx && 'object' !== typeof ctx) { + this.throwErr('TypeError', 'initLog', 'ctx must be object or ' + + 'function'); + } + this.log = function() { + var args, i, len; + len = arguments.length; + args = new Array(len); + for (i = 0; i < len; i++) { + args[i] = arguments[i]; + } + return cb.apply(ctx, args); + }; + }; + + /** + * ### NDDB._getConstrName + * + * Returns 'NDDB' or the name of the inheriting class. + */ + NDDB.prototype._getConstrName = function() { + return this.constructor && this.constructor.name ? + this.constructor.name : 'NDDB'; + }; + + // ## CORE + + /** + * ### NDDB._autoUpdate + * + * Updates pointer, indexes, and sort items + * + * What is updated depends on configuration stored in `this.__update`. + * + * @param {object} options Optional. Configuration object + * + * @see NDDB.__update + * + * @api private + */ + NDDB.prototype._autoUpdate = function(options) { + var u; + u = this.__update; + options = options || {}; + + if (options.pointer || + ('undefined' === typeof options.pointer && u.pointer)) { + + this.nddb_pointer = this.db.length-1; + } + if (options.sort || + ('undefined' === typeof options.sort && u.sort)) { + + this.sort(); + } + if (options.indexes || + ('undefined' === typeof options.indexes && u.indexes)) { + + this.rebuildIndexes(); + } + }; + + /** + * ### NDDB.importDB + * + * Imports an array of items at once + * + * @param {array} db Array of items to import + */ + NDDB.prototype.importDB = function(db) { + var i, len; + if (!J.isArray(db)) { + this.throwErr('TypeError', 'importDB', 'db must be array. Found: ' + + db); + } + i = -1, len = db.length; + for ( ; ++i < len ; ) { + nddb_insert.call(this, db[i], this.__update.indexes); + } + this._autoUpdate({indexes: false}); + }; + + /** + * ### NDDB.insert + * + * Insert an item into the database + * + * Item must be of type object or function. + * + * The following entries will be ignored: + * + * - strings + * - numbers + * - undefined + * - null + * + * @param {object} o The item or array of items to insert + * @param {object} updateRules Optional. Update rules to overwrite + * system-wide settings stored in `this.__update` + * + * @return {object|boolean} o The inserted object (might have been + * updated by on('insert') callbacks), or FALSE if the object could + * not be inserted, e.g. if a on('insert') callback returned FALSE. + * + * @see NDDB.__update + * @see nddb_insert + */ + NDDB.prototype.insert = function(o, updateRules) { + var res; + if ('undefined' === typeof updateRules) { + updateRules = this.__update; + } + else if ('object' !== typeof updateRules) { + this.throwErr('TypeError', 'insert', + 'updateRules must be object or undefined. Found: ', + updateRules); + } + res = nddb_insert.call(this, o, updateRules.indexes); + if (res === false) return false; + // If updateRules.indexes is false, then we do not want to do it. + // If it was true, we did it already. + this._autoUpdate({ + indexes: false, + pointer: updateRules.pointer, + sort: updateRules.sort + }); + return o; + }; + + /** + * ### NDDB.size + * + * Returns the number of elements in the database + * + * It always returns the length of the full database, regardless of + * current selection. + * + * @return {number} The total number of elements in the database + * + * @see NDDB.count + */ + NDDB.prototype.size = function() { + return this.db.length; + }; + + /** + * ### NDDB.slice + * + * Creates a clone of the current NDDB object + * + * Takes care of calling the actual constructor of the class, + * so that inheriting objects will preserve their prototype. + * + * @param {array} db Optional. Array of items to import in the new database. + * Default, items currently in the database + * + * @return {NDDB|object} The new database + */ + NDDB.prototype.slice = function(start, end) { + if ('number' !== typeof start) { + this.throwErr('TypeError', 'slice', 'start must be number. ' + + 'Found: ' + start); + } + if ('undefined' !== typeof end && 'number' !== typeof end) { + this.throwErr('TypeError', 'slice', 'end must be number or ' + + 'undefined. Found: ' + end); + } + // In case the class was inherited. + return this.breed(this.fetch().slice(start, end)); + }; + + /** + * ### NDDB.breed + * + * Creates a clone of the current NDDB object + * + * Takes care of calling the actual constructor of the class, + * so that inheriting objects will preserve their prototype. + * + * @param {array} db Optional. Array of items to import in the new database. + * Default, items currently in the database + * + * @return {NDDB|object} The new database + */ + NDDB.prototype.breed = function(db) { + if (db && !J.isArray(db)) { + this.throwErr('TypeError', 'breed', 'db must be array ' + + 'or undefined. Found: ' + db); + } + // In case the class was inherited. + return new this.constructor(this.cloneSettings(), db || this.fetch()); + }; + + /** + * ### NDDB.cloneSettings + * + * Creates a clone of the configuration of this instance + * + * Clones: + * - the hashing, indexing, comparator, and view functions + * - the current tags + * - the update settings + * - the callback hooks + * - the globalCompare callback + * + * Copies by reference: + * - the shared objects + * - the log and logCtx options (might have cyclyc structures) + * + * It is possible to specifies the name of the properties to leave out + * out of the cloned object as a parameter. By default, all options + * are cloned. + * + * @param {object} leaveOut Optional. An object containing the name of + * the properties to leave out of the clone as keys. + * + * @return {object} options A copy of the current settings + * plus the shared objects + */ + NDDB.prototype.cloneSettings = function(leaveOut) { + var i, options, keepShared; + var logCopy, logCtxCopy; + options = this.__options || {}; + keepShared = true; + + options.H = this.__H; + options.I = this.__I; + options.C = this.__C; + options.V = this.__V; + options.tags = this.tags; + options.update = this.__update; + options.hooks = this.hooks; + options.globalCompare = this.globalCompare; + options.filters = this.__userDefinedFilters; + options.formats = this.__formats; + options.defaultFormat = this.__defaultFormat; + options.wd = this.__wd; + + // Must be removed before cloning. + if (options.log) { + logCopy = options.log; + delete options.log; + } + // Must be removed before cloning. + if (options.logCtx) { + logCtxCopy = options.logCtx; + delete options.logCtx; + } + + // Cloning. + options = J.clone(options); + + // Removing unwanted options. + for (i in leaveOut) { + if (leaveOut.hasOwnProperty(i)) { + if (i === 'shared') { + // 'shared' is not in `options`, we just have + // to remember not to add it later. + keepShared = false; + continue; + } + delete options[i]; + } + } + + if (keepShared) { + options.shared = this.__shared; + } + if (logCopy) { + options.log = logCopy; + this.__options.log = logCopy; + } + if (logCtxCopy) { + options.logCtx = logCtxCopy; + this.__options.logCtx = logCtxCopy; + } + + return options; + }; + + /** + * ### NDDB.toString + * + * Returns a human-readable representation of the database + * + * @return {string} out A human-readable representation of the database + */ + NDDB.prototype.toString = function() { + var out, i; + out = ''; + for (i = 0; i < this.db.length; i++) { + out += this.db[i] + "\n"; + } + return out; + }; + + /** + * ### NDDB.stringify + * + * Stringifies the items in the database in *JSON format + * + * Cyclic objects are decycled, functions, null, undefined, are kept. + * + * Evaluates pending queries with `fetch`. + * + * @param {object} opts Configuration options: + * - enclose: adds [] around all items. Default: false. + * - comma: separates items with a comma. Default: false. + * - pretty: pretty-print items. Default: false + * - lineBreak: line-break separator. Default: os.EOL or '\n'; + * - decycle: Decycle ciclic objects. Default: true. + * + * @return {string} out A machine-readable representation of the database + * + * @see JSUS.stringify + */ + NDDB.prototype.stringify = (function() { + + function stringifyItem(item, lineBreak, spaces, comma, decycle) { + var item, res, re; + // TODO: merge stringify and decycle in one. + if (decycle) item = NDDB.decycle(item); + res = J.stringify(item, spaces); + // Auto-escaped. + // if (stripLineBreaks) { + // re = new RegExp(lineBreak, 'g'); + // res = res.replace(re, lineBreakReplace); + // } + if (comma) res += ', '; + if (lineBreak) res += lineBreak; + return res; + }; + + return function(opts) { + var db, i, len, out; + var spaces, lineBreak, decycle; + + opts = opts || {}; + + if (!this.size()) return opts.enclose ? '[]' : ''; + + decycle = opts.decycle !== false; + lineBreak = opts.lineBreak || NDDB.lineBreak; + + spaces = opts.pretty ? 4 : 0; + out = opts.enclose ? '[' + lineBreak : ''; + + db = this.fetch(); + + + // Main loop. + i = -1, len = (db.length -1); + for ( ; ++i < len ; ) { + out += stringifyItem(db[i], lineBreak, spaces, + opts.comma, decycle); + } + // Last item (no comma). + out += stringifyItem(db[i], lineBreak, spaces, false, decycle); + + if (opts.enclose) out += ']'; + return out; + }; + })(); + + + + + + /** + * ### NDDB.comparator + * + * Registers a comparator function for dimension d + * + * Each time a comparison between two objects containing + * property named as the specified dimension, the registered + * comparator function will be used. + * + * @param {string} d The name of the dimension + * @param {function} comparator The comparator function + */ + NDDB.prototype.comparator = function(d, comparator) { + if ('string' !== typeof d) { + this.throwErr('TypeError', 'comparator', 'd must be string'); + } + if ('function' !== typeof comparator) { + this.throwErr('TypeError', 'comparator', 'comparator ' + + 'must be function'); + } + this.__C[d] = comparator; + }; + + /** + * ### NDDB.getComparator + * + * Retrieves the comparator function for dimension d. + * + * If no comparator function is found, returns a general comparator + * function. Supports nested attributes search, but if a property + * containing dots with the same name is found, this will + * returned first. + * + * The dimension can be the wildcard '*' or an array of dimesions. + * In the latter case a custom comparator function is built on the fly. + * + * @param {string|array} d The name/s of the dimension/s + * @return {function} The comparator function + * + * @see NDDB.compare + */ + NDDB.prototype.getComparator = function(d) { + var i, len, comparator, comparators; + + // Given field or '*'. + if ('string' === typeof d) { + if ('undefined' !== typeof this.__C[d]) { + comparator = this.__C[d]; + } + else { + comparator = function generalComparator(o1, o2) { + var v1, v2; + if ('undefined' === typeof o1 && + 'undefined' === typeof o2) return 0; + if ('undefined' === typeof o1) return 1; + if ('undefined' === typeof o2) return -1; + + if ('undefined' !== typeof o1[d]) { + v1 = o1[d]; + } + else if (d.lastIndexOf('.') !== -1) { + v1 = J.getNestedValue(d, o1); + } + + if ('undefined' !== typeof o2[d]) { + v2 = o2[d]; + } + else if (d.lastIndexOf('.') !== -1) { + v2 = J.getNestedValue(d, o2); + } + + if ('undefined' === typeof v1 && + 'undefined' === typeof v2) return 0; + if ('undefined' === typeof v1) return 1; + if ('undefined' === typeof v2) return -1; + if (v1 > v2) return 1; + if (v2 > v1) return -1; + + // In case v1 and v2 are of different types + // they might not be equal here. + if (v2 === v1) return 0; + + // Return 1 if everything else fails. + return 1; + }; + } + } + // Pre-defined array o fields to check. + else { + // Creates the array of comparators functions. + comparators = {}; + len = d.length; + for (i = 0; i < len; i++) { + // Every comparator has its own d in scope. + // TODO: here there should be no wildcard '*' (check earlier) + comparators[d[i]] = this.getComparator(d[i]); + } + + comparator = function(o1, o2, trigger1, trigger2) { + var i, res, obj; + for (i in comparators) { + if (comparators.hasOwnProperty(i)) { + if ('undefined' === typeof o1[i]) continue; + obj = {}; + obj[i] = o2; + res = comparators[i](o1, obj); + if (res === trigger1) return res; + if ('undefined' !== trigger2 && res === trigger2) { + return res; + } + } + } + // We are not interested in sorting. + // Figuring out the right return value + if (trigger1 === 0) { + return trigger2 === 1 ? -1 : 1; + } + if (trigger1 === 1) { + return trigger2 === 0 ? -1 : 0; + } + + return trigger2 === 0 ? 1 : 0; + + }; + } + return comparator; + }; + + /** + * ### NDDB.isReservedWord + * + * Returns TRUE if a key is a reserved word + * + * A word is reserved if a property or a method with + * the same name already exists in the current instance + * + * @param {string} key The name of the property + * + * @return {boolean} TRUE, if the property exists + */ + NDDB.prototype.isReservedWord = function(key) { + return (this[key]) ? true : false; + }; + + /** + * ### NDDB.index + * + * Registers a new indexing function + * + * Indexing functions give fast direct access to the + * entries of the dataset. + * + * A new object `NDDB[idx]` is created, whose properties + * are the elements indexed by the function. + * + * An indexing function must return a _string_ with a unique name of + * the property under which the entry will registered, or _undefined_ if + * the entry does not need to be indexed. + * + * @param {string} idx The name of index + * @param {function} func Optional. The hashing function. Default: a + * function that returns the property named after the index + * + * @see NDDB.isReservedWord + * @see NDDB.rebuildIndexes + */ + NDDB.prototype.index = function(idx, func) { + if (('string' !== typeof idx) && ('number' !== typeof idx)) { + this.throwErr('TypeError', 'index', 'idx must be string or number'); + } + if (this.isReservedWord(idx)) { + this.throwErr('TypeError', 'index', 'idx is reserved word: ' + idx); + } + if ('undefined' === typeof func) { + func = function(item) { return item[idx]; }; + } + else if ('function' !== typeof func) { + this.throwErr('TypeError', 'index', 'func must be function or ' + + 'undefined. Found: ' + func); + } + this.__I[idx] = func, this[idx] = new NDDBIndex(idx, this); + }; + + /** + * ### NDDB.view + * + * Registers a new view function + * + * View functions create a _view_ on the database that + * excludes automatically some of the entries. + * + * A nested NDDB dataset is created as `NDDB[idx]`, containing + * all the items that the callback function returns. If the + * callback returns _undefined_ the entry will be ignored. + * + * @param {string} idx The name of index + * @param {function} func Optional. The hashing function. Default: a + * function that returns the property named after the index + * + * @see NDDB.hash + * @see NDDB.isReservedWord + * @see NDDB.rebuildIndexes + */ + NDDB.prototype.view = function(idx, func) { + var settings; + if (('string' !== typeof idx) && ('number' !== typeof idx)) { + this.throwErr('TypeError', 'view', 'idx must be string or number'); + } + if (this.isReservedWord(idx)) { + this.throwErr('TypeError', 'view', 'idx is reserved word: ' + idx); + } + if ('undefined' === typeof func) { + // View checks for undefined later. + func = function(item) { return item[idx]; }; + } + else if ('function' !== typeof func) { + this.throwErr('TypeError', 'view', 'func must be function or ' + + 'undefined. Found: ' + func); + } + // Create a copy of the current settings, without the views and hooks + // functions, else we create an infinite loop in the constructor or + // hooks are executed multiple times. + this.__V[idx] = func; + settings = this.cloneSettings( { V: true, hooks: true} ); + settings.name = idx; + this[idx] = new NDDB(settings); + // Reference to this instance. + this[idx].__parentDb = this; + + return this[idx]; + }; + + /** + * ### NDDB.hash + * + * Registers a new hashing function + * + * Hash functions create an index containing multiple sub-_views_. + * + * A new object `NDDB[idx]` is created, whose properties + * are _views_ on the original dataset. + * + * An hashing function must return a _string_ representing the + * view under which the entry will be added, or _undefined_ if + * the entry does not belong to any view of the index. + * + * @param {string} idx The name of index + * @param {function} func Optional. The hashing function. Default: a + * function that returns the property named after the index + * + * @see NDDB.view + * @see NDDB.isReservedWord + * @see NDDB.rebuildIndexes + */ + NDDB.prototype.hash = function(idx, func) { + if (('string' !== typeof idx) && ('number' !== typeof idx)) { + this.throwErr('TypeError', 'hash', 'idx must be string or number'); + } + if (this.isReservedWord(idx)) { + this.throwErr('TypeError', 'hash', 'idx is reserved word: ' + idx); + } + if ('undefined' === typeof func) { + func = function(item) { return item[idx]; }; + } + else if ('function' !== typeof func) { + this.throwErr('TypeError', 'hash', 'func must be function or ' + + 'undefined. Found: ' + func); + } + this[idx] = {}; // new NDDBHash(); + this.__H[idx] = func; + + }; + + /** + * ### NDDB.resetIndexes + * + * Resets all the database indexes, hashs, and views + * + * @see NDDB.rebuildIndexes + * @see NDDB.index + * @see NDDB.view + * @see NDDB.hash + * @see NDDB._indexIt + * @see NDDB._viewIt + * @see NDDB._hashIt + */ + NDDB.prototype.resetIndexes = function(options) { + var key, reset; + reset = options || J.merge({ + h: true, + v: true, + i: true + }, options); + + if (reset.h) { + for (key in this.__H) { + if (this.__H.hasOwnProperty(key)) { + this[key] = {}; + } + } + } + if (reset.v) { + for (key in this.__V) { + if (this.__V.hasOwnProperty(key)) { + this[key] = new this.constructor(); + } + } + } + if (reset.i) { + for (key in this.__I) { + if (this.__I.hasOwnProperty(key)) { + this[key] = new NDDBIndex(key, this); + } + } + } + + }; + + /** + * ### NDDB.rebuildIndexes + * + * Rebuilds all the database indexes, hashs, and views + * + * @see NDDB.resetIndexes + * @see NDDB.index + * @see NDDB.view + * @see NDDB.hash + * @see NDDB._indexIt + * @see NDDB._viewIt + * @see NDDB._hashIt + */ + NDDB.prototype.rebuildIndexes = function() { + var h, i, v, cb, idx; + + h = !(J.isEmpty(this.__H)); + i = !(J.isEmpty(this.__I)); + v = !(J.isEmpty(this.__V)); + + if (!h && !i && !v) return; + + if (h && !i && !v) { + cb = this._hashIt; + } + else if (!h && i && !v) { + cb = this._indexIt; + } + else if (!h && !i && v) { + cb = this._viewIt; + } + else if (h && i && !v) { + cb = function(o, idx) { + this._hashIt(o); + this._indexIt(o, idx); + }; + } + else if (!h && i && v) { + cb = function(o, idx) { + this._indexIt(o, idx); + this._viewIt(o); + }; + } + else if (h && !i && v) { + cb = function(o) { + this._hashIt(o); + this._viewIt(o); + }; + } + else { + cb = function(o, idx) { + this._indexIt(o, idx); + this._hashIt(o); + this._viewIt(o); + }; + } + + // Reset current indexes. + this.resetIndexes({h: h, v: v, i: i}); + + for (idx = 0 ; idx < this.db.length ; idx++) { + // _hashIt and viewIt do not need idx, it is no harm anyway + cb.call(this, this.db[idx], idx); + } + }; + + /** + * ### NDDB._indexIt + * + * Indexes an element + * + * Parameter _oldIdx_ is needed if indexing is updating a previously + * indexed item. In fact if new index is different, the old one must + * be deleted. + * + * @param {object} o The element to index + * @param {number} dbidx The position of the element in the database array + * @param {string} oldIdx Optional. The old index name, if any. + */ + NDDB.prototype._indexIt = function(o, dbidx, oldIdx) { + var func, index, key; + if (!o || J.isEmpty(this.__I)) return; + + for (key in this.__I) { + if (this.__I.hasOwnProperty(key)) { + func = this.__I[key]; + index = func(o); + // If the same object has been previously + // added with another index delete the old one. + if (index !== oldIdx) { + if ('undefined' !== typeof oldIdx) { + if ('undefined' !== typeof this[key].resolve[oldIdx]) { + this[key]._remove(oldIdx); + } + } + } + if ('undefined' !== typeof index) { + if (!this[key]) this[key] = new NDDBIndex(key, this); + this[key]._add(index, dbidx); + } + } + } + }; + + /** + * ### NDDB._viewIt + * + * Adds an element to a view + * + * @param {object} o The element to index + * + * @see NDDB.view + */ + NDDB.prototype._viewIt = function(o) { + var func, index, key, settings; + if (!o || J.isEmpty(this.__V)) return false; + + for (key in this.__V) { + if (this.__V.hasOwnProperty(key)) { + func = this.__V[key]; + index = func(o); + if ('undefined' === typeof index) { + // Element must be deleted, if already in hash. + if (!this[key]) continue; + if ('undefined' !== typeof + this[key].nddbid.resolve[o._nddbid]) { + + this[key].nddbid.remove(o._nddbid); + } + continue; + } + //this.__V[idx] = func, this[idx] = new this.constructor(); + + // TODO: When is the view not already created? Check! + if (!this[key]) { + // Create a copy of the current settings, + // without the views functions, otherwise + // we establish an infinite loop in the + // constructor, and the hooks. + settings = this.cloneSettings({ V: true, hooks: true }); + settings.name = key; + // console.log('saving...', this.name, this.size()); + + this[key] = new NDDB(settings); + // Reference to this instance. + this[key].__parentDb = this; + } + this[key].insert(o); + } + } + }; + + /** + * ### NDDB._hashIt + * + * Hashes an element + * + * @param {object} o The element to hash + * + * @see NDDB.hash + */ + NDDB.prototype._hashIt = function(o) { + var h, hash, key, settings, oldHash; + if (!o || J.isEmpty(this.__H)) return false; + + for (key in this.__H) { + if (this.__H.hasOwnProperty(key)) { + h = this.__H[key]; + hash = h(o); + + if ('undefined' === typeof hash) { + oldHash = this.hashtray.get(key, o._nddbid); + if (oldHash) { + this[key][oldHash].nddbid.remove(o._nddbid); + this.hashtray.remove(key, o._nddbid); + } + continue; + } + if (!this[key]) this[key] = {}; + + if (!this[key][hash]) { + // Create a copy of the current settings, + // without the hashing functions, otherwise + // we create an infinite loop at first insert, + // and the hooks (should be called only on main db). + settings = this.cloneSettings({ H: true, hooks: true }); + settings.name = hash; + this[key][hash] = new NDDB(settings); + // Reference to this instance. + this[key][hash].__parentDb = this; + } + this[key][hash].insert(o); + this.hashtray.set(key, o._nddbid, hash); + } + } + }; + + // ## Event emitter / listener + + /** + * ### NDDB.on + * + * Registers an event listeners + * + * Available events: + * + * - `insert`: each time an item is inserted + * - `remove`: each time an item, or a collection of items, is removed + * - `update`: each time an item is updated + * + * Examples. + * + * ```javascript + * var db = new NDDB(); + * + * var trashBin = new NDDB(); + * + * db.on('insert', function(item) { + * item.id = getMyNextId(); + * }); + * + * db.on('remove', function(array) { + * trashBin.importDB(array); + * }); + * ``` + * + * @param {string} event The name of an event + * @param {function} func The callback function associated to the event + * @param {boolean} shared Optional. Experimental. If TRUE, this event + * is shared with all nested databases. Careful! It may created + * infinite loops. Default: FALSE. + * + * @see NDDB.emit + * @experimental shared parameter + */ + NDDB.prototype.on = function(event, func, shared) { + if ('string' !== typeof event) { + this.throwErr('TypeError', 'on', 'event must be string. Found: ' + + event); + } + if ('function' !== typeof func) { + this.throwErr('TypeError', 'on', 'func must be function. Found: ' + + func); + } + if (!this.hooks[event]) { + this.throwErr('TypeError', 'on', 'unknown event: ' + event); + } + this.hooks[event].push(func); + if (shared) this.sharedHooks[event].push(func); + }; + + /** + * ### NDDB.off + * + * Deregister an event, or an event listener + * + * @param {string} event The event name + * @param {function} func Optional. The specific function to deregister. + * If empty, all the event listensers for `event` are cleared. + * + * @return {boolean} TRUE, if the removal is successful + */ + NDDB.prototype.off = function(event, func) { + var i; + if ('string' !== typeof event) { + this.throwErr('TypeError', 'off', 'event must be string'); + } + if (func && 'function' !== typeof func) { + this.throwErr('TypeError', 'off', + 'func must be function or undefined'); + } + if (!this.hooks[event]) { + this.throwErr('TypeError', 'off', 'unknown event: ' + event); + } + if (!this.hooks[event].length) return false; + + if (!func) { + this.hooks[event] = []; + this.sharedHooks[event] = []; + return true; + } + for (i = 0; i < this.hooks[event].length; i++) { + // Shared hooks contains at most as many items as hooks, but + // probably much less. + if (this.sharedHooks[event][i] == func) { + this.sharedHooks[event].splice(i, 1); + } + if (this.hooks[event][i] == func) { + this.hooks[event].splice(i, 1); + return true; + } + } + return false; + }; + + /** + * ### NDDB.emit + * + * Fires all the listeners associated with an event (optimized) + * + * Accepts any number of parameters, the first one is the name + * of the event, and the remaining will be passed to the event listeners. + * + * If a registered event listener returns FALSE, subsequent event + * listeners are **not** executed, and the method returns FALSE. + * + * @param {string} The event type ('insert', 'delete', 'update') + * + * @return {boolean} TRUE under normal conditions, or FALSE if at least + * one callback function returned FALSE. + */ + NDDB.prototype.emit = function() { + var event, hooks; + var h, h2; + var i, len, argLen, args; + var res; + event = arguments[0]; + if ('string' !== typeof event) { + this.throwErr('TypeError', 'emit', 'first argument must be string'); + } + + hooks = this.hooks[event]; + if (!hooks) { + this.throwErr('TypeError', 'emit', 'unknown event: ' + event); + } + + // If this is a child db (e.g. a hash or a view) must fire also the + // parent hooks. Local hooks fire first. + // Check: all events should be fired on the parent? E.g., setWD? + if (this.__parentDb) { + hooks = hooks.length ? + hooks.concat(this.__parentDb.sharedHooks[event]) : + this.__parentDb.sharedHooks[event]; + } + + len = hooks.length; + if (!len) return true; + argLen = arguments.length; + + switch(len) { + + case 1: + h = hooks[0]; + if (argLen === 1) res = h.call(this); + else if (argLen === 2) res = h.call(this, arguments[1]); + else if (argLen === 3) { + res = h.call(this, arguments[1], arguments[2]); + } + else { + args = new Array(argLen-1); + for (i = 0; i < argLen; i++) { + args[i] = arguments[i+1]; + } + res = h.apply(this, args); + } + break; + case 2: + h = hooks[0], h2 = hooks[1]; + if (argLen === 1) { + res = h.call(this) !== false; + res = res && h2.call(this) !== false; + } + else if (argLen === 2) { + res = h.call(this, arguments[1]) !== false; + res = res && h2.call(this, arguments[1]) !== false; + } + else if (argLen === 3) { + res = h.call(this, arguments[1], arguments[2]) !== false; + res = res && h2.call(this, arguments[1], arguments[2])!== false; + } + else { + args = new Array(argLen-1); + for (i = 0; i < argLen; i++) { + args[i] = arguments[i+1]; + } + res = h.apply(this, args) !== false; + res = res && h2.apply(this, args) !== false; + } + break; + default: + if (argLen === 1) { + for (i = 0; i < len; i++) { + res = hooks[i].call(this) !== false; + if (res === false) break; + } + } + else if (argLen === 2) { + res = true; + for (i = 0; i < len; i++) { + res = hooks[i].call(this, arguments[1]) !== false; + if (res === false) break; + } + } + else if (argLen === 3) { + res = true; + for (i = 0; i < len; i++) { + res = hooks[i].call(this, arguments[1], + arguments[2]) !== false; + if (res === false) break; + } + } + else { + args = new Array(argLen-1); + for (i = 0; i < argLen; i++) { + args[i] = arguments[i+1]; + } + res = true; + for (i = 0; i < len; i++) { + res = hooks[i].apply(this, args) !== false; + if (res === false) break; + } + + } + } + return res; + }; + + // ## Sort and Select + + function queryError(text, d, op, value) { + var miss, err; + miss = '(?)'; + err = this._getConstrName() + '._analyzeQuery: ' + text + + '. Malformed query: ' + d || miss + ' ' + op || miss + + ' ' + value || miss + '.'; + throw new Error(err); + } + + /** + * ### NDDB._analyzeQuery + * + * Validates and prepares select queries before execution + * + * @api private + * @param {string} d The dimension of comparison + * @param {string} op The operation to perform + * @param {string} value The right-hand element of comparison + * @return {boolean|object} The object-query or FALSE, + * if an error was detected + */ + NDDB.prototype._analyzeQuery = function(d, op, value) { + var i, len, errText; + + if ('undefined' === typeof d) { + queryError.call(this, 'undefined dimension', d, op, value); + } + + // Verify input. + if ('undefined' !== typeof op) { + + if (op === '=') { + op = '=='; + } + else if (op === '!==') { + op = '!='; + } + + if (!(op in this.filters)) { + queryError.call(this, 'unknown operator ' + op, d, op, value); + } + + // Range-queries need an array as third parameter instance of Array. + if (J.inArray(op,['><', '<>', 'in', '!in'])) { + + if (!(value instanceof Array)) { + errText = 'range-queries need an array as third parameter'; + queryError.call(this, errText, d, op, value); + } + if (op === '<>' || op === '><') { + + // It will be nested by the comparator function. + if (!J.isArray(d)){ + // TODO: when to nest and when keep the '.' in the name? + value[0] = J.setNestedValue(d, value[0]); + value[1] = J.setNestedValue(d, value[1]); + } + } + } + + else if (J.inArray(op, ['!=', '>', '==', '>=', '<', '<='])){ + // Comparison queries need a third parameter. + if ('undefined' === typeof value) { + errText = 'value cannot be undefined in comparison queries'; + queryError.call(this, errText, d, op, value); + } + // TODO: when to nest and when keep the '.' in the name? + // Comparison queries need to have the same + // data structure in the compared object + if (J.isArray(d)) { + len = d.length; + for (i = 0; i < len; i++) { + J.setNestedValue(d[i],value); + } + + } + else { + value = J.setNestedValue(d,value); + } + } + + // other (e.g. user-defined) operators do not have constraints, + // e.g. no need to transform the value + + } + else if ('undefined' !== typeof value) { + errText = 'undefined filter and defined value'; + queryError.call(this, errText, d, op, value); + } + else { + op = 'E'; // exists + value = ''; + } + + return { d:d, op:op, value:value }; + }; + + /** + * ### NDDB.distinct + * + * Eliminates duplicated entries + * + * A new database is returned and the original stays unchanged + * + * @return {NDDB} A copy of the current selection without duplicated entries + * + * @see NDDB.select() + * @see NDDB.fetch() + * @see NDDB.fetchValues() + */ + NDDB.prototype.distinct = function() { + return this.breed(J.distinct(this.db)); + }; + + /** + * ### NDDB.select + * + * Initiates a new query selection procedure + * + * Input parameters: + * + * - d: string representation of the dimension used to filter. Mandatory. + * - op: operator for selection. Allowed: >, <, >=, <=, = (same as ==), + * ==, ===, !=, !==, in (in array), !in, >< (not in interval), + * <> (in interval) + * - value: values of comparison. The following operators require + * an array: in, !in, ><, <>. + * + * Important!! No actual selection is performed until + * the `execute` method is called, so that further selections + * can be chained with the `or`, and `and` methods. + * + * To retrieve the items use one of the fetching methods. + * + * @param {string} d The dimension of comparison + * @param {string} op Optional. The operation to perform + * @param {mixed} value Optional. The right-hand element of comparison + * + * @return {NDDB} A new NDDB instance with the currently + * selected items in memory + * + * @see NDDB.and + * @see NDDB.or + * @see NDDB.execute() + * @see NDDB.fetch() + */ + NDDB.prototype.select = function(d, op, value) { + this.query.reset(); + return arguments.length ? this.and(d, op, value) : this; + }; + + /** + * ### NDDB.and + * + * Chains an AND query to the current selection + * + * @param {string} d The dimension of comparison + * @param {string} op Optional. The operation to perform + * @param {mixed} value Optional. The right-hand element of comparison + * + * @return {NDDB} A new NDDB instance with the currently + * selected items in memory + * + * @see NDDB.select + * @see NDDB.or + * @see NDDB.execute() + */ + NDDB.prototype.and = function(d, op, value) { + // TODO: Support for nested query + // if (!arguments.length) { + // addBreakInQuery(); + // } + // else { + var q, cb; + q = this._analyzeQuery(d, op, value); + cb = this.filters[q.op](q.d, q.value, this.getComparator(q.d)); + this.query.addCondition('AND', cb); + // } + return this; + }; + + /** + * ### NDDB.or + * + * Chains an OR query to the current selection + * + * @param {string} d The dimension of comparison + * @param {string} op Optional. The operation to perform + * @param {mixed} value Optional. The right-hand element of comparison + * + * @return {NDDB} A new NDDB instance with the currently + * selected items in memory + * + * @see NDDB.select + * @see NDDB.and + * @see NDDB.execute() + */ + NDDB.prototype.or = function(d, op, value) { + // TODO: Support for nested query + // if (!arguments.length) { + // addBreakInQuery(); + // } + // else { + var q, cb; + q = this._analyzeQuery(d, op, value); + cb = this.filters[q.op](q.d, q.value, this.getComparator(q.d)); + this.query.addCondition('OR', cb); + //this.query.addCondition('OR', condition, this.getComparator(d)); + // } + return this; + }; + + + /** + * ### NDDB.selexec + * + * Shorthand for select and execute methods + * + * Adds a single select condition and executes it. + * + * @param {string} d The dimension of comparison + * @param {string} op Optional. The operation to perform + * @param {mixed} value Optional. The right-hand element of comparison + * + * @return {NDDB} A new NDDB instance with the currently + * selected items in memory + * + * @see NDDB.select + * @see NDDB.and + * @see NDDB.or + * @see NDDB.execute + * @see NDDB.fetch + */ + NDDB.prototype.selexec = function(d, op, value) { + return this.select(d, op, value).execute(); + }; + + /** + * ### NDDB.execute + * + * Returns a new NDDB instance containing only the items currently selected + * + * This method is deprecated and might not longer be supported in future + * versions of NDDB. Use NDDB.breed instead. + * + * Does not reset the query object, and it is possible to reuse the current + * selection multiple times. + * + * @param {string} d The dimension of comparison + * @param {string} op Optional. The operation to perform + * @param {mixed} value Optional. The right-hand element of comparison + * + * @return {NDDB} A new NDDB instance with selected items in the db + * + * @see NDDB.select + * @see NDDB.selexec + * @see NDDB.and + * @see NDDB.or + * + * @deprecated + */ + NDDB.prototype.execute = function() { + return this.filter(this.query.get.call(this.query)); + }; + + /** + * ### NDDB.exists + * + * Returns TRUE if a copy of the object exists in the database / selection + * + * @param {object} o The object to look for + * + * @return {boolean} TRUE, if a copy is found + * + * @see JSUS.equals + * @see NDDB.fetch + */ + NDDB.prototype.exists = function(o) { + var i, len, db; + if ('object' !== typeof o && 'function' !== typeof o) { + this.throwErr('TypeError', 'exists', + 'o must be object or function'); + } + db = this.fetch(); + len = db.length; + for (i = 0 ; i < len ; i++) { + if (J.equals(db[i], o)) return true; + } + return false; + }; + + /** + * ### NDDB.limit + * + * Breeds a new NDDB instance with only the first N entries + * + * If a selection is active it will apply the limit to the + * current selection only. + * + * If limit is a negative number, selection is made starting + * from the end of the database. + * + * @param {number} limit The number of entries to include + * + * @return {NDDB} A "limited" copy of the current instance of NDDB + * + * @see NDDB.breed + * @see NDDB.first + * @see NDDB.last + */ + NDDB.prototype.limit = function(limit) { + var db; + if ('number' !== typeof limit) { + this.throwErr('TypeError', 'exists', 'limit must be number'); + } + db = this.fetch(); + if (limit !== 0) { + db = (limit > 0) ? db.slice(0, limit) : db.slice(limit); + } + return this.breed(db); + }; + + /** + * ### NDDB.reverse + * + * Reverses the order of all the entries in the database / selection + * + * @see NDDB.sort + */ + NDDB.prototype.reverse = function() { + this.db.reverse(); + return this; + }; + + /** + * ### NDDB.sort + * + * Sort the db according to one of the several criteria. + * + * Available sorting options: + * + * - globalCompare function, if no parameter is passed + * - one of the dimension, if a string is passed + * - a custom comparator function + * + * A reference to the current NDDB object is returned, so that + * further methods can be chained. + * + * Notice: the order of entries is changed. + * + * @param {string|array|function} d Optional. The criterium of sorting + * + * @return {NDDB} A sorted copy of the current instance of NDDB + * + * @see NDDB.globalCompare + */ + NDDB.prototype.sort = function(d) { + var func, that; + + // Global compare. + if (!d) { + func = this.globalCompare; + } + // User-defined function. + else if ('function' === typeof d) { + func = d; + } + // Array of dimensions. + else if (d instanceof Array) { + that = this; + func = function(a,b) { + var i, result; + for (i = 0; i < d.length; i++) { + result = that.getComparator(d[i]).call(that, a, b); + if (result !== 0) return result; + } + return result; + }; + } + // Single dimension. + else { + func = this.getComparator(d); + } + + this.db.sort(func); + return this; + }; + + /** + * ### NDDB.shuffle + * + * Returns a copy of the current database with randomly shuffled items + * + * @param {boolean} update Optional. If TRUE, items in the current database + * are also shuffled. Defaults, FALSE. + * + * @return {NDDB} A new instance of NDDB with the shuffled entries + */ + NDDB.prototype.shuffle = function(update) { + var shuffled; + shuffled = J.shuffle(this.db); + if (update) { + this.db = shuffled; + this.rebuildIndexes(); + } + return this.breed(shuffled); + }; + + /** + * ### NDDB.random + * + * Breeds a new database with N randomly selected items + * + * @param {number} N How many random items to include + * + * @return {NDDB} A new instance of NDDB with the shuffled entries + */ + NDDB.prototype.random = function(N, strict) { + var i, len, used, out, idx; + if ('number' !== typeof N) { + this.throwErr('TypeError', 'random', + 'N must be number Found: ' + N); + } + if (N < 1) { + this.throwErr('Error', 'random', 'N must be > 0. Found: ' + N); + } + len = this.db.length; + if (N > len && strict !== false) { + this.throwErr('Error', 'random', 'not enough items in db. Found: ' + + len + '. Requested: ' + N); + } + // Heuristic. + if (N < (len/3)) { + i = 0; + out = new Array(N); + used = {}; + while (i < N) { + idx = J.randomInt(0, len)-1; + if ('undefined' === typeof used[idx]) { + used[idx] = true; + out[i] = this.db[idx]; + i++; + } + } + } + else { + out = J.shuffle(this.db); + out = out.slice(0, N); + } + return this.breed(out); + }; + + // ## Custom callbacks + + /** + * ### NDDB.table + * + * Returns the frequency table for the specified indexes + * + * TODO: support multiple indexes, at least two. + * TODO: support returning a sorted array. + * TODO: keep table in memory if key is already an index + * + * @param {string} idx The name of first index + * + * @return {object} res An object containing the frequency table + */ + NDDB.prototype.table = function(idx) { + var res, db, i, v; + db = this.fetch(); + res = {}; + for (i = 0; i < db.length; i++) { + v = db[i][idx]; + if ('undefined' !== typeof v) { + if ('undefined' === typeof res[v]) res[v] = 1; + else res[v]++; + } + } + return res; + }; + + /** + * ### NDDB.filter + * + * Filters the entries according to a user-defined function + * + * If a selection is active it will filter items only within the + * current selection. + * + * A new NDDB instance is breeded. + * + * @param {function} func The filtering function + * + * @return {NDDB} A new instance of NDDB containing the filtered entries + * + * @see NDDB.breed + */ + NDDB.prototype.filter = function(func) { + return this.breed(this.fetch().filter(func)); + }; + + /** + * ### NDDB.each || NDDB.forEach (optimized) + * + * Applies a callback function to each element in the db + * + * If a selection is active, the callback will be applied to items + * within the current selection only. + * + * It accepts a variable number of input arguments, but the first one + * must be a valid callback, and all the following are passed as parameters + * to the callback + * + * @see NDDB.map + */ + NDDB.prototype.each = NDDB.prototype.forEach = function() { + var func, i, db, len, args, argLen; + func = arguments[0]; + if ('function' !== typeof func) { + this.throwErr('TypeError', 'each', + 'first argument must be function'); + } + db = this.fetch(); + len = db.length; + argLen = arguments.length; + switch(argLen) { + case 1: + for (i = 0 ; i < len ; i++) { + func.call(this, db[i]); + } + break; + case 2: + for (i = 0 ; i < len ; i++) { + func.call(this, db[i], arguments[1]); + } + break; + case 3: + for (i = 0 ; i < len ; i++) { + func.call(this, db[i], arguments[1], arguments[2]); + } + break; + default: + args = new Array(argLen+1); + args[0] = null; + for (i = 1; i < argLen; i++) { + args[i] = arguments[i]; + } + for (i = 0 ; i < len ; i++) { + args[0] = db[i]; + func.apply(this, args); + } + } + }; + + /** + * ### NDDB.map + * + * Maps a callback to each element of the db and returns an array + * + * It accepts a variable number of input arguments, but the first one + * must be a valid callback, and all the following are passed as + * parameters to the callback. + * + * @return {array} out The result of the mapping + * + * @see NDDB.each + */ + NDDB.prototype.map = function() { + var func, i, db, len, out, o; + var args, argLen; + func = arguments[0]; + if ('function' !== typeof func) { + this.throwErr('TypeError', 'map', + 'first argument must be function'); + } + db = this.fetch(); + len = db.length; + argLen = arguments.length; + out = []; + switch(argLen) { + case 1: + for (i = 0 ; i < len ; i++) { + o = func.call(this, db[i]); + if ('undefined' !== typeof o) out.push(o); + } + break; + case 2: + for (i = 0 ; i < len ; i++) { + o = func.call(this, db[i], arguments[1]); + if ('undefined' !== typeof o) out.push(o); + } + break; + case 3: + for (i = 0 ; i < len ; i++) { + o = func.call(this, db[i], arguments[1], arguments[2]); + if ('undefined' !== typeof o) out.push(o); + } + break; + default: + args = new Array(argLen+1); + args[0] = null; + for (i = 1; i < argLen; i++) { + args[i] = arguments[i]; + } + for (i = 0 ; i < len ; i++) { + args[0] = db[i]; + o = func.apply(this, args); + if ('undefined' !== typeof o) out.push(o); + } + } + return out; + }; + + // ## Update + + /** + * ### NDDB.update + * + * Updates all selected entries + * + * Mixins the properties of the _update_ object in each of the + * selected items. + * + * Some selected items can be skipped from update if a callback + * on('update') returns FALSE. + * + * @param {object} update An object containing the properties + * that will be updated. + * @param {object} updateRules Optional. Update rules to overwrite + * system-wide settings stored in `this.__update` + * + * @return {NDDB} A new instance of NDDB with updated entries + * + * @see JSUS.mixin + * @see NDDB.emit + */ + NDDB.prototype.update = function(update, updateRules) { + var i, len, db, res; + if ('object' !== typeof update) { + this.throwErr('TypeError', 'update', + 'update must be object. Found: ', update); + } + if ('undefined' === typeof updateRules) { + updateRules = this.__update; + } + else if ('object' !== typeof updateRules) { + this.throwErr('TypeError', 'update', + 'updateRules must be object or undefined. Found: ', + updateRules); + } + // Gets items and resets the current selection. + db = this.fetch(); + len = db.length; + if (len) { + for (i = 0; i < len; i++) { + res = this.emit('update', db[i], update, i); + if (res === true) { + J.mixin(db[i], update); + if (updateRules.indexes) { + this._indexIt(db[i]); + this._hashIt(db[i]); + this._viewIt(db[i]); + } + } + } + // If updateRules.indexes is false, then we do not want to do it. + // If it was true, we did it already + this._autoUpdate({ + indexes: false, + pointer: updateRules.pointer, + sort: updateRules.sort + }); + } + return this; + }; + + // ## Deletion + + /** + * ### NDDB.removeAllEntries + * + * Removes all entries from the database + * + * @return {NDDB} A new instance of NDDB with no entries + */ + NDDB.prototype.removeAllEntries = function() { + console.log('***NDDB.removeAllEntries is deprecated. Use ' + + 'NDDB.clear instead***'); + if (!this.db.length) return this; + this.emit('remove', this.db); + this.nddbid.resolve = {}; + this.db = []; + this._autoUpdate(); + return this; + }; + + /** + * ### NDDB.clear + * + * Removes all volatile data + * + * Removes all entries, indexes, hashes, views, and tags, + * and resets the current query selection + * + * Hooks, indexing, comparator, views, and hash functions are not deleted. + */ + NDDB.prototype.clear = function() { + var i; + + this.db = []; + this.nddbid.resolve = {}; + this.tags = {}; + this.query.reset(); + this.nddb_pointer = 0; + this.lastSelection = []; + this.hashtray.clear(); + + for (i in this.__H) { + if (this[i]) this[i] = null; + } + for (i in this.__C) { + if (this[i]) this[i] = null; + } + for (i in this.__I) { + if (this[i]) this[i] = null; + } + }; + + + // ## Advanced operations + + /** + * ### NDDB.join + * + * Performs a *left* join across all the entries of the database + * + * @param {string} key1 First property to compare + * @param {string} key2 Second property to compare + * @param {string} pos Optional. The property under which the join + * is performed. Defaults 'joined' + * @param {string|array} select Optional. The properties to copy + * in the join. Defaults undefined + * + * @return {NDDB} A new database containing the joined entries + * + * @see NDDB._join + * @see NDDB.breed + * + * TODO: allow join on multiple properties. + */ + NDDB.prototype.join = function(key1, key2, pos, select) { + // + return this._join(key1, key2, J.equals, pos, select); + }; + + /** + * ### NDDB.concat + * + * Copies the (sub)entries with 'key2' in all the entries with 'key1' + * + * Nested properties can be accessed with '.'. + * + * @param {string} key1 First property to compare + * @param {string} key2 Second property to compare + * @param {string} pos Optional. The property under which the join is + * performed. Defaults 'joined' + * @param {string|array} select Optional. The properties to copy in + * the join. Defaults undefined + * + * @return {NDDB} A new database containing the concatenated entries + * + * @see NDDB._join + * @see JSUS.join + */ + NDDB.prototype.concat = function(key1, key2, pos, select) { + return this._join(key1, key2, function(){ return true; }, pos, select); + }; + + /** + * ### NDDB._join + * + * Performs a *left* join across all the entries of the database + * + * The values of two keys (also nested properties are accepted) are compared + * according to the specified comparator callback, or using `JSUS.equals`. + * + * If the comparator function returns TRUE, matched entries are appended + * as a new property of the matching one. + * + * By default, the full object is copied in the join, but it is possible to + * specify the name of the properties to copy as an input parameter. + * + * A new NDDB object breeded, so that further methods can be chained. + * + * @param {string} key1 First property to compare + * @param {string} key2 Second property to compare + * @param {function} comparator Optional. A comparator function. + * Defaults, `JSUS.equals` + * @param {string} pos Optional. The property under which the join + * is performed. Defaults 'joined' + * @param {string|array} select Optional. The properties to copy + * in the join. Defaults undefined + * + * @return {NDDB} A new database containing the joined entries + * + * @see NDDB.breed + * + * @api private + */ + NDDB.prototype._join = function(key1, key2, comparator, pos, select) { + var out, foreign_key, key; + var i, j, o, o2; + if (!key1 || !key2) return this.breed([]); + + comparator = comparator || J.equals; + pos = ('undefined' !== typeof pos) ? pos : 'joined'; + if (select) { + select = (select instanceof Array) ? select : [select]; + } + + out = []; + for (i = 0; i < this.db.length; i++) { + + foreign_key = J.getNestedValue(key1, this.db[i]); + if ('undefined' !== typeof foreign_key) { + for (j = i+1; j < this.db.length; j++) { + + key = J.getNestedValue(key2, this.db[j]); + + if ('undefined' !== typeof key) { + if (comparator(foreign_key, key)) { + // Inject the matched obj into the reference one. + o = J.clone(this.db[i]); + o2 = select ? + J.subobj(this.db[j], select) : this.db[j]; + o[pos] = o2; + out.push(o); + } + } + } + } + } + return this.breed(out); + }; + + /** + * ### NDDB.split + * + * Splits recursively all the entries containing the specified dimension + * + * If a active selection if found, operation is applied only to the subset. + * + * A NDDB object is breeded containing all the split items. + * + * @param {string} key The dimension along which items will be split + * @param {number} level Optional. Limits how deep to perform the split. + * Value equal to 0 means no limit in the recursive split. + * @param {boolean} positionAsKey Optional. If TRUE, when splitting an + * array the position of an element is used as key. Default: FALSE. + * + * @return {NDDB} A new database containing the split entries + * + * @see JSUS.split + */ + NDDB.prototype.split = function(key, level, positionAsKey) { + var out, i, db, len; + if ('string' !== typeof key) { + this.throwErr('TypeError', 'split', 'key must be string'); + } + db = this.fetch(); + len = db.length; + out = []; + for (i = 0; i < len; i++) { + out = out.concat(J.split(db[i], key, level, positionAsKey)); + } + return this.breed(out); + }; + + // ## Fetching + + /** + * ### NDDB.fetch + * + * Returns array of selected entries in the database + * + * If no selection criteria is specified returns all entries. + * + * By default, it resets the current selection, and further calls to + * `fetch` will return the full database. + * + * It stores a reference to the most recent array of selected items + * under `this.lastSelection`. + * + * Examples: + * + * ```javascript + * var db = new NDDB(); + * db.importDB([ { a: 1, b: {c: 2}, d: 3 } ]); + * + * db.fetch(); // [ { a: 1, b: {c: 2}, d: 3 } ] + * + * db.select('a', '=', 1); + * + * db.fetch(); // [ { a: 1 } ] + * ``` + * + * No further chaining is permitted after fetching. + * + * @param {boolean} doNotReset Optional. If TRUE, it does not reset + * the current selection. Default, TRUE + * + * @return {array} out The fetched values + * + * @see NDDB.fetchValues + * @see NDDB.fetchArray + * @see NDDB.fetchKeyArray + * @see NDDB.fetchSubObj + * @see NDDB.lastSelection + */ + NDDB.prototype.fetch = function(doNotReset) { + var db; + if (this.db.length && this.query.query.length) { + if (doNotReset && 'boolean' !== typeof doNotReset) { + this.throwErr('TypeError', 'fetch', + 'doNotReset must be undefined or boolean'); + } + db = this.db.filter(this.query.get.call(this.query)); + if (!doNotReset) this.query.reset(); + } + else { + db = this.db; + } + this.lastSelection = db; + return db; + }; + + /** + * ### NDDB.fetchSubObj + * + * Fetches all the entries in the database and trims out unwanted properties + * + * Examples + * + * ```javascript + * var db = new NDDB(); + * db.insert([ { a:1, b:{c:2}, d:3 } ]); + * db.insert([ { a:4, b:{c:5}, d:6 } ]); + * + * db.fetchSubObj('a'); // [ { a: 1} , {a: 4}] + * ``` + * + * No further chaining is permitted after fetching. + * + * @param {string|array} key Optional. If set, returned objects will + * have only such properties + * + * @return {array} out The fetched objects + * + * @see NDDB.fetch + * @see NDDB.fetchValues + * @see NDDB.fetchArray + * @see NDDB.fetchKeyArray + */ + NDDB.prototype.fetchSubObj= function(key) { + var i, el, db, out; + if (!key) return []; + db = this.fetch(), out = []; + for (i = 0; i < db.length; i++) { + el = J.subobj(db[i], key); + if (!J.isEmpty(el)) out.push(el); + } + return out; + }; + + + /** + * ### NDDB.fetchValues + * + * Fetches all the values of the entries in the database + * + * The type of the input parameter determines the return value: + * - `string`: returned value is a one-dimensional array. + * - `array`: returned value is an object whose properties + * are arrays containing all the values found for those keys. + * + * Nested properties can be specified too. + * + * Examples + * + * ```javascript + * var db = new NDDB(); + * db.insert([ { a:1, b:{c:2}, d:3 } ]); + * + * db.fetchValues(); // [ [ 1, 2, 3 ] ] + * db.fetchValues('b'); // { b: [ {c: 2} ] } + * db.fetchValues('d'); // { d: [ 3 ] }; + * + * db.insert([ { a:4, b:{c:5}, d:6 } ]); + * + * db.fetchValues([ 'a', 'd' ]); // { a: [ 1, 4] , d: [ 3, 6] }; + * ``` + * + * No further chaining is permitted after fetching. + * + * @param {string|array} key Optional. If set, returns only + * the value from the specified property + * + * @return {array} out The fetched values + * + * @see NDDB.fetch + * @see NDDB.fetchArray + * @see NDDB.fetchKeyArray + * @see NDDB.fetchSubObj + */ + NDDB.prototype.fetchValues = function(key) { + var db, el, i, out, typeofkey; + + db = this.fetch(); + + typeofkey = typeof key, out = {}; + + if (typeofkey === 'undefined') { + for (i=0; i < db.length; i++) { + J.augment(out, db[i], J.keys(db[i])); + } + } + + else if (typeofkey === 'string') { + out[key] = []; + for (i=0; i < db.length; i++) { + el = J.getNestedValue(key, db[i]); + if ('undefined' !== typeof el) { + out[key].push(el); + } + } + } + + else if (J.isArray(key)) { + out = J.melt(key, J.rep([], key.length)); // object not array + for ( i = 0 ; i < db.length ; i++) { + el = J.subobj(db[i], key); + if (!J.isEmpty(el)) { + J.augment(out, el); + } + } + } + + return out; + }; + + function getValuesArray(o) { + return J.obj2Array(o, 1); + } + + function getKeyValuesArray(o) { + return J.obj2KeyedArray(o, 1); + } + + + function getValuesArray_KeyString(o, key) { + var el = J.getNestedValue(key, o); + if ('undefined' !== typeof el) { + return J.obj2Array(el, 1); + } + } + + function getValuesArray_KeyArray(o, key) { + var el = J.subobj(o, key); + if (!J.isEmpty(el)) { + return J.obj2Array(el, 1); + } + } + + + function getKeyValuesArray_KeyString(o, key) { + var el = J.getNestedValue(key, o); + if ('undefined' !== typeof el) { + return key.split('.').concat(J.obj2KeyedArray(el)); + } + } + + function getKeyValuesArray_KeyArray(o, key) { + var el = J.subobj(o, key); + if (!J.isEmpty(el)) { + return J.obj2KeyedArray(el); + } + } + + /** + * ### NDDB._fetchArray + * + * Low level primitive for fetching the entities as arrays + * + * Examples + * + * ```javascript + * var db = new NDDB(); + * var items = [{a:1, b:2}, {a:3, b:4}, {a:5, c:6}]; + * db.importDB(items); + * + * db._fetch(null, 'VALUES'); + * // [ [ 1, 2 ], [ 3, 4 ], [ 5, 6] ] + * + * db._fetch(null, 'KEY_VALUES'); + * // [ [ 'a', 1, 'b', 2 ], [ 'a', 3, 'b', 4 ], [ 'a', 5, 'c', 6 ] ] + * + * db._fetch('a', 'VALUES'); + * // [ [ 1 ], [ 3 ], [ 5 ] ] + * + * db._fetch('a', 'KEY_VALUES'); + * // [ [ 'a', 1 ], [ 'a', 3 ], [ 'a', 5 ] ] + * + * db._fetch(['a','b'], 'VALUES'); + * // [ [ 1 , 2], [ 3, 4 ], [ 5 ] ] + * + * db._fetch([ 'a', 'c'] 'KEY_VALUES'); + * // [ [ 'a', 1 ], [ 'a', 3 ], [ 'a', 5, 'c', 6 ] ] + * ``` + * + * No further chaining is permitted after fetching. + * + * @api private + * @param {string|array} key Optional. If set, returns key/values only + * from the specified property + * @param {boolean} keyed. Optional. If set, also the keys are returned + * + * @return {array} out The fetched values + */ + NDDB.prototype._fetchArray = function(key, keyed) { + var db, cb, out, el, i; + + if (keyed) { + + if (!key) cb = getKeyValuesArray; + + else if ('string' === typeof key) { + cb = getKeyValuesArray_KeyString; + } + else { + cb = getKeyValuesArray_KeyArray; + } + } + else { + if (!key) cb = getValuesArray; + + else if ('string' === typeof key) { + cb = getValuesArray_KeyString; + } + else { + cb = getValuesArray_KeyArray; + } + } + + db = this.fetch(), out = []; + for (i = 0; i < db.length; i++) { + el = cb.call(db[i], db[i], key); + if ('undefined' !== typeof el) out.push(el); + } + + return out; + }; + + /** + * ### NDDB.fetchArray + * + * Fetches the entities in the database as arrays instead of objects + * + * Examples + * + * ```javascript + * var db = new NDDB(); + * db.insert([ { a:1, b:{c:2}, d:3 } ]); + * db.insert([ { a:4, b:{c:5}, d:6 } ]); + * + * db.fetchArray(); // [ [ 1, 'c', 2, 3 ], ] + * db.fetchArray('b'); // [ [ 'c', 2 ] ] + * db.fetchArray('d'); // [ [ 3 ] ] + * ``` + * + * No further chaining is permitted after fetching. + * + * @see NDDB._fetchArray + * @see NDDB.fetchValues + * @see NDDB.fetchKeyArray + * @see NDDB.fetchSubObj + */ + NDDB.prototype.fetchArray = function(key) { + return this._fetchArray(key); + }; + + /** + * ### NDDB.fetchKeyArray + * + * Like NDDB.fetchArray, but also the keys are added + * + * Examples + * + * ```javascript + * var db = new NDDB(); + * db.insert([ { a:1, b:{c:2}, d:3 } ]); + * + * db.fetchKeyArray(); // [ [ 'a', 1, 'c', 2, 'd', 3 ] ] + * db.fetchKeyArray('b'); // [ [ 'b', 'c', 2 ] ] + * db.fetchKeyArray('d'); // [ [ 'd', 3 ] ] + * ``` + * + * No further chaining is permitted after fetching. + * + * @param {string} key Optional. If set, returns only the value + * from the specified property + * + * @return {array} out The fetched values + * + * @see NDDB._fetchArray + * @see NDDB.fetchArray + * @see NDDB.fetchValues + * @see NDDB.fetchSubObj + */ + NDDB.prototype.fetchKeyArray = function(key) { + return this._fetchArray(key, true); + }; + + /** + * ### NDDB.groupBy + * + * Splits the entries in the database in subgroups + * + * Each subgroup is formed up by elements which have the + * same value along the specified dimension. + * + * An array of NDDB instances is returned, therefore no direct + * method chaining is allowed afterwards. + * + * Entries containing undefined values in the specified + * dimension will be skipped + * + * Examples + * + * ```javascript + * var db = new NDDB(); + * var items = [{a:1, b:2}, {a:3, b:4}, {a:5}, {a:6, b:2}]; + * db.importDB(items); + * + * var groups = db.groupBy('b'); + * groups.length; // 2 + * + * groups[0].fetch(); // [ { a: 1, b: 2 }, { a: 6, b: 2 } ] + * + * groups[1].fetch(); // [ { a: 3, b: 4 } ] + * ``` + * + * @param {string} key The dimension for grouping + * + * @return {array} outs The array of NDDB (or constructor) groups + */ + NDDB.prototype.groupBy = function(key) { + var groups, outs, i, el, out, db; + db = this.fetch(); + if (!key) return db; + + groups = [], outs = []; + for (i = 0 ; i < db.length ; i++) { + el = J.getNestedValue(key, db[i]); + if ('undefined' === typeof el) continue; + // Creates a new group and add entries to it. + if (!J.inArray(el, groups)) { + groups.push(el); + out = this.filter(function(elem) { + if (J.equals(J.getNestedValue(key, elem), el)) { + return elem; + } + }); + // Reset nddb_pointer in subgroups. + out.nddb_pointer = 0; + outs.push(out); + } + } + return outs; + }; + + // ## Statistics + + /** + * ### NDDB.count + * + * Counts the entries containing the specified key + * + * If key is undefined, the size of the databse is returned. + * + * @param {string} key The dimension to count + * + * @return {number} count The number of items along the specified dimension + * + * @see NDDB.size + */ + NDDB.prototype.count = function(key) { + var i, count, len, db; + db = this.fetch(); + len = db.length; + if ('undefined' === typeof key) return len; + if ('string' !== typeof key) { + this.throwErr('TypeError', 'count', + 'key must be string or undefined'); + } + count = 0; + for (i = 0; i < len; i++) { + if (J.hasOwnNestedProperty(key, db[i])){ + count++; + } + } + return count; + }; + + /** + * ### NDDB.sum + * + * Returns the sum of the values of all the entries with the specified key + * + * Non numeric values are ignored. + * + * @param {string} key The dimension to sum + * + * @return {number} sum The sum of the values for the dimension, + * or NaN if it does not exist + */ + NDDB.prototype.sum = function(key) { + var sum, i, len, tmp, db; + if ('string' !== typeof key) { + this.throwErr('TypeError', 'sum', 'key must be string'); + } + db = this.fetch(), len = db.length, sum = NaN; + for (i = 0; i < len; i++) { + tmp = J.getNestedValue(key, db[i]); + if (!isNaN(tmp)) { + if (isNaN(sum)) sum = 0; + sum += tmp; + } + } + return sum; + }; + + /** + * ### NDDB.mean + * + * Returns the mean of the values of all the entries with the specified key + * + * Entries with non numeric values are ignored, and excluded + * from the computation of the mean. + * + * @param {string} key The dimension to average + * + * @return {number} The mean of the values for the dimension, + * or NaN if it does not exist + */ + NDDB.prototype.mean = function(key) { + var sum, count, tmp, db; + var i, len; + if ('string' !== typeof key) { + this.throwErr('TypeError', 'mean', 'key must be string'); + } + db = this.fetch(); + len = db.length; + sum = 0, count = 0; + for (i = 0; i < len; i++) { + tmp = J.getNestedValue(key, db[i]); + if (!isNaN(tmp)) { + sum += tmp; + count++; + } + } + return (count === 0) ? NaN : sum / count; + }; + + /** + * ### NDDB.stddev + * + * Returns the std. dev. of the values of the entries with the specified key + * + * It uses the computational formula for sample standard deviation, + * using N - 1 at the denominator of the sum of squares. + * + * Entries with non numeric values are ignored, and excluded + * from the computation of the standard deviation. + * + * @param {string} key The dimension to average + * + * @return {number} The standard deviations of the values for the dimension, + * or NaN if it does not exist + */ + NDDB.prototype.stddev = function(key) { + var count, tmp, db, i, len; + var sum, sumSquared; + if ('string' !== typeof key) { + this.throwErr('TypeError', 'stddev', 'key must be string'); + } + db = this.fetch(); + len = db.length; + if (!len || len === 1) return NaN; + i = -1; + sum = 0, sumSquared = 0, count = 0; + for ( ; ++i < len ; ) { + tmp = J.getNestedValue(key, db[i]); + if (!isNaN(tmp)) { + count++; + sum += tmp; + sumSquared += Math.pow(tmp, 2); + } + } + tmp = sumSquared - (Math.pow(sum, 2) / count); + return Math.sqrt( tmp / (count - 1) ); + }; + + /** + * ### NDDB.min + * + * Returns the min of the values of all the entries + * in the database containing the specified key. + * + * Entries with non numeric values are ignored. + * + * @param {string} key The dimension of which to find the min + * + * @return {number} The smallest value for the dimension, + * or NaN if it does not exist + * + * @see NDDB.max + */ + NDDB.prototype.min = function(key) { + var min, tmp, db, i, len; + if ('string' !== typeof key) { + this.throwErr('TypeError', 'min', 'key must be string'); + } + db = this.fetch(); + len = db.length; + min = NaN; + for (i = 0; i < len; i++) { + tmp = J.getNestedValue(key, db[i]); + if (!isNaN(tmp) && (tmp < min || isNaN(min))) { + min = tmp; + } + } + return min; + }; + + /** + * ### NDDB.max + * + * Returns the max of the values of all the entries + * in the database containing the specified key. + * + * Entries with non numeric values are ignored. + * + * @param {string} key The dimension of which to find the max + * + * @return {number} The biggest value for the dimension, + * or NaN if it does not exist + * + * @see NDDB.min + */ + NDDB.prototype.max = function(key) { + var max, i, len, tmp, db; + if ('string' !== typeof key) { + this.throwErr('TypeError', 'max', 'key must be string'); + } + db = this.fetch(); + len = db.length; + max = NaN; + for (i = 0; i < len; i++) { + tmp = J.getNestedValue(key, db[i]); + if (!isNaN(tmp) && (tmp > max || isNaN(max))) { + max = tmp; + } + } + return max; + }; + + // ## Skim + + /** + * ### NDDB.skim + * + * Removes the specified properties from the items + * + * If a active selection if found, operation is applied only to the subset. + * + * Use '.' (dot) to point to a nested property. + * + * Items with no property are automatically removed. + * + * @param {string|array} skim The selection of properties to remove + * + * @return {NDDB} A new database containing the result of the skim + * + * @see NDDB.keep + * @see JSUS.skim + */ + NDDB.prototype.skim = function(skim) { + if ('string' !== typeof skim && !J.isArray(skim)) { + this.throwErr('TypeError', 'skim', 'skim must be string or array'); + } + return this.breed(this.map(function(e){ + var skimmed = J.skim(e, skim); + if (!J.isEmpty(skimmed)) { + return skimmed; + } + })); + }; + + /** + * ### NDDB.keep + * + * Removes all the properties that are not specified from the items + * + * If a active selection if found, operation is applied only to the subset. + * + * Use '.' (dot) to point to a nested property. + * + * Items with no property are automatically removed. + * + * @param {string|array} skim The selection of properties to keep + + * @return {NDDB} A new database containing the result of the keep operation + * + * @see NDDB.skim + * @see JSUS.keep + */ + NDDB.prototype.keep = function(keep) { + if ('string' !== typeof keep && !J.isArray(keep)) { + this.throwErr('TypeError', 'keep', 'keep must be string or array'); + } + return this.breed(this.map(function(e){ + var subobj = J.subobj(e, keep); + if (!J.isEmpty(subobj)) { + return subobj; + } + })); + }; + + // ## Diff + + + /** + * ### NDDB.diff + * + * Performs a diff of the entries of a specified databases + * + * Returns a new NDDB instance containing all the entries that + * are present in the current instance, and *not* in the + * database obj passed as parameter. + * + * @param {NDDB|array} nddb The external database to compare + * + * @return {NDDB} A new database containing the result of the diff + * + * @see NDDB.intersect + * @see JSUS.arrayDiff + */ + NDDB.prototype.diff = function(nddb) { + if (!J.isArray(nddb)) { + if ('object' !== typeof nddb || !J.isArray(nddb.db)) { + this.throwErr('TypeError', 'diff', + 'nddb must be array or NDDB'); + } + nddb = nddb.db; + } + if (!nddb.length) { + return this.breed([]); + } + return this.breed(J.arrayDiff(this.fetch(), nddb)); + }; + + /** + * ### NDDB.intersect + * + * Finds the entries in common with a specified database + * + * Returns a new NDDB instance containing all the entries that + * are present both in the current instance of NDDB and in the + * database obj passed as parameter. + * + * @param {NDDB|array} nddb The external database to compare + * + * @return {NDDB} A new database containing the result of the intersection + * + * @see NDDB.diff + * @see JSUS.arrayIntersect + */ + NDDB.prototype.intersect = function(nddb) { + if (!J.isArray(nddb)) { + if ('object' !== typeof nddb || !J.isArray(nddb.db)) { + this.throwErr('TypeError', 'intersect', + 'nddb must be array or NDDB'); + } + nddb = nddb.db; + } + if (!nddb.length) { + return this.breed([]); + } + return this.breed(J.arrayIntersect(this.fetch(), nddb)); + }; + + + // ## Iterator + + /** + * ### NDDB.get + * + * Returns the entry at the given numerical position + * + * @param {number} pos The position of the entry + * + * @return {object|undefined} The requested item, or undefined if + * the index is invalid + */ + NDDB.prototype.get = function(pos) { + if ('number' !== typeof pos) { + this.throwErr('TypeError', 'get', 'pos must be number'); + } + return this.db[pos]; + }; + + /** + * ### NDDB.current + * + * Returns the entry at which the iterator is currently pointing + * + * The pointer is *not* updated. + * + * @return {object|undefined} The current entry, or undefined if the + * pointer is at an invalid position + */ + NDDB.prototype.current = function() { + return this.db[this.nddb_pointer]; + }; + + /** + * ### NDDB.next + * + * Moves the pointer to the next entry in the database and returns it + * + * @return {object|undefined} The next entry, or undefined + * if none is found + * + * @see NDDB.previous + */ + NDDB.prototype.next = function() { + var el; + this.nddb_pointer++; + el = NDDB.prototype.current.call(this); + if (!el) this.nddb_pointer--; + return el; + }; + + /** + * ### NDDB.previous + * + * Moves the pointer to the previous entry in the database and returns it + * + * @return {object|undefined} The previous entry, or undefined + * if none is found + * + * @see NDDB.next + */ + NDDB.prototype.previous = function() { + var el; + this.nddb_pointer--; + el = NDDB.prototype.current.call(this); + if (!el) this.nddb_pointer++; + return el; + }; + + /** + * ### NDDB.first + * + * Returns the last entry in the current selection / database + * + * Returns undefined if the current selection / database is empty. + * + * @param {string} updatePointer Optional. If set, the pointer + * is not moved to the first entry (if any) + * + * @return {object} The first entry found + * + * @see NDDB.last + * @see NDDB.fetch + * @see NDDB.nddb_pointer + */ + NDDB.prototype.first = function(doNotUpdatePointer) { + var db = this.fetch(); + if (db.length) { + if (!doNotUpdatePointer) this.nddb_pointer = 0; + return db[0]; + } + return undefined; + }; + + /** + * ### NDDB.last + * + * Returns the last entry in the current selection / database + * + * Returns undefined if the current selection / database is empty. + * + * @param {string} doNotUpdatePointer Optional. If set, the pointer is not + * moved to the last entry (if any) + * + * @return {object} The last entry found + * + * @see NDDB.first + * @see NDDB.fetch + * @see NDDB.nddb_pointer + */ + NDDB.prototype.last = function(doNotUpdatePointer) { + var db = this.fetch(); + if (db.length) { + if (!doNotUpdatePointer) this.nddb_pointer = db.length-1; + return db[db.length-1]; + } + return undefined; + }; + + // ## Tagging + + + /** + * ### NDDB.tag + * + * Registers a tag associated to an object + * + * The second parameter can be the index of an object + * in the database, the object itself, or undefined. In + * the latter case, the current value of `nddb_pointer` + * is used to create the reference. + * + * The tag is independent from sorting and deleting operations, + * but changes on update of the elements of the database. + * + * @param {string|number} tag An alphanumeric id + * @param {mixed} idx Optional. The reference to the object. + * Defaults, last element in db + * @return {object} ref A reference to the tagged object + * + * @see NDDB.resolveTag + */ + NDDB.prototype.tag = function(tag, idx) { + var ref, typeofIdx; + if ('string' !== typeof tag && 'number' !== typeof tag) { + this.throwErr('TypeError', 'tag', 'tag must be string or number'); + } + + ref = null, typeofIdx = typeof idx; + + if (typeofIdx === 'undefined') { + ref = this.db[this.db.length-1]; + } + else if (typeofIdx === 'number') { + + if (idx > this.length || idx < 0) { + this.throwErr('Error', 'tag', 'invalid index provided: ' + idx); + } + ref = this.db[idx]; + } + else { + ref = idx; + } + + this.tags[tag] = ref; + return ref; + }; + + /** + * ### NDDB.resolveTag + * + * Returns the element associated with the given tag. + * + * @param {string} tag An alphanumeric id + * + * @return {object} The object associated with the tag + * + * @see NDDB.tag + */ + NDDB.prototype.resolveTag = function(tag) { + if ('string' !== typeof tag) { + this.throwErr('TypeError', 'resolveTag', 'tag must be string'); + } + return this.tags[tag]; + }; + + // ## Save/Load. + + + /** + * ### NDDB.load + * + * Reads items in the specified format and loads them into db asynchronously + * + * @param {string} file The name of the file or other persistent storage + * @param {object} opts Optional. A configuration object. Available + * options are format-dependent. + * @param {function} cb Optional. A callback function to execute at + * the end of the operation. If options is not specified, + * cb is the second parameter. + * + * @see NDDB.loadSync + */ + NDDB.prototype.load = function(file, opts, cb) { + return executeSaveLoad(this, 'load', file, cb, opts); + }; + + /** + * ### NDDB.save + * + * Saves items in the specified format asynchronously + * + * @see NDDB.saveSync + */ + NDDB.prototype.save = function(file, opts, cb) { + return executeSaveLoad(this, 'save', file, cb, opts); + }; + + /** + * ### NDDB.loadSync + * + * Reads items in the specified format and loads them into db synchronously + * + * @see NDDB.load + */ + NDDB.prototype.loadSync = function(file, opts, cb) { + return executeSaveLoad(this, 'loadSync', file, cb, opts); + }; + + /** + * ### NDDB.saveSync + * + * Saves items in the specified format synchronously + * + * @see NDDB.save + */ + NDDB.prototype.saveSync = function(file, opts, cb) { + return executeSaveLoad(this, 'saveSync', file, cb, opts); + }; + + // ## Formats. + + /** + * ### NDDB.addFormat + * + * Registers a _format_ function + * + * The format object is of the type: + * + * { + * load: function() {}, // Async + * save: function() {}, // Async + * loadSync: function() {}, // Sync + * saveSync: function() {} // Sync + * } + * + * @param {string|array} format The format name/s + * @param {object} The format object containing at least one + * pair of save/load functions (sync and async) + */ + NDDB.prototype.addFormat = function(format, obj) { + var f, i, len; + validateFormatParameters(this, format, obj); + if (!J.isArray(format)) format = [format]; + i = -1, len = format.length; + for ( ; ++i < len ; ) { + f = format[i]; + if ('string' !== typeof f || f.trim() === '') { + this.throwErr('TypeError', 'addFormat', 'format must be ' + + 'a non-empty string'); + } + this.__formats[f] = obj; + } + }; + + /** + * ### NDDB.getFormat + * + * Returns the requested _format_ function + * + * @param {string} format The format name + * @param {string} method Optional. One of: + * `save`,`load`,`saveString`,`loadString`. + * + * @return {function|object} Format object or function or NULL if not found. + */ + NDDB.prototype.getFormat = function(format, method) { + var f; + + f = this.__formats[format]; + if (f && method) f = f[method]; + return f || null; + }; + + /** + * ### NDDB.setDefaultFormat + * + * Sets the default format + * + * @param {string} format The format name or null + * + * @see NDDB.getDefaultFormat + */ + NDDB.prototype.setDefaultFormat = function(format) { + if (format !== null && + ('string' !== typeof format || format.trim() === '')) { + + this.throwErr('TypeError', 'setDefaultFormat', 'format must be ' + + 'a non-empty string or null'); + } + if (format && !this.__formats[format]) { + this.throwErr('Error', 'setDefaultFormat', 'unknown format: ' + + format); + } + this.__defaultFormat = format; + }; + + /** + * ### NDDB.getDefaultFormat + * + * Returns the default format + * + * @see NDDB.setDefaultFormat + */ + NDDB.prototype.getDefaultFormat = function() { + return this.__defaultFormat; + }; + + /** + * ### NDDB.addDefaultFormats + * + * Dummy property. If overwritten it will be invoked by constructor + */ + NDDB.prototype.addDefaultFormats = null; + + // ## Helper Methods + + /** + * ### nddb_insert + * + * Insert an item into db and performs update operations + * + * A new property `.nddbid` is created in the object, and it will be + * used to add the element into the global index: `NDDB.nddbid`. + * + * Emits the 'insert' event, and updates indexes, hashes and views + * accordingly. + * + * @param {object|function} o The item to add to database + * @param {boolean} doUpdate Optional. If TRUE, updates indexes, hashes, + * and views. Default, FALSE + * + * @return {boolean} TRUE, if item was inserted, FALSE otherwise, e.g. + * if a callback on('insert') returned FALSE. + * + * @see NDDB.nddbid + * @see NDDB.emit + * + * @api private + */ + function nddb_insert(o, doUpdate) { + var nddbid, res; + if (('object' !== typeof o) && ('function' !== typeof o)) { + this.throwErr('TypeError', 'insert', 'object or function ' + + 'expected, ' + typeof o + ' received'); + } + + // Check / create a global index. + if ('undefined' === typeof o._nddbid) { + // Create internal idx. + nddbid = J.uniqueKey(this.nddbid.resolve); + if (!nddbid) { + this.throwErr('Error', 'insert', + 'failed to create index: ' + o); + } + if (df) { + Object.defineProperty(o, '_nddbid', { value: nddbid }); + } + else { + o._nddbid = nddbid; + } + } + // Add to index directly (bypass api). + this.nddbid.resolve[o._nddbid] = this.db.length; + // End create index. + res = this.emit('insert', o, this.db.length); + // Stop inserting elements if one callback returned FALSE. + if (res === false) return false; + this.db.push(o); + if (doUpdate) { + this._indexIt(o, (this.db.length-1)); + this._hashIt(o); + this._viewIt(o); + } + return true + } + + /** + * ### validateSaveLoadParameters + * + * Validates the parameters of a call to save, saveSync, load, loadSync + * + * @param {NDDB} that The reference to the current instance + * @param {string} method The name of the method invoking validation + * @param {string} file The file parameter + * @param {function} cb The callback parameter + * @param {object} The options parameter + */ + function validateSaveLoadParameters(that, method, file, cb, options) { + if ('string' !== typeof file || file.trim() === '') { + that.throwErr('TypeError', method, 'file must be ' + + 'a non-empty string. Found: ' + file); + } + if (cb && 'function' !== typeof cb) { + that.throwErr('TypeError', method, 'cb must be function ' + + 'or undefined. Found: ' + cb); + } + if (options && 'object' !== typeof options) { + if ('function' !== typeof options || 'undefined' !== typeof cb) { + that.throwErr('TypeError', method, 'options must be object ' + + 'or undefined. Found: ' + options); + } + } + } + + /** + * ### getExtension + * + * Extracts the extension from a file name + * + * @param {string} file The filename + * + * @return {string} The extension or NULL if not found + */ + function getExtension(file) { + var format; + format = file.lastIndexOf('.'); + return format < 0 ? null : file.substr(format+1); + } + + /** + * ### executeSaveLoad + * + * Executes save, saveSync, load, or loadSync for the requested format + * + * Evaluates pending queries with `fetch`. + * Technical note: for the JSON format, queries are fetched by + * the `stringify` method, for the CSV format, by the `saveCsv`. + * + * @param {NDDB} that The reference to the current instance + * @param {string} method The name of the method invoking validation + * @param {string} file The file parameter + * @param {function} cb The callback parameter + * @param {object} options The options parameter + * + * @return {NDDB} that The current instance for chaining + */ + function executeSaveLoad(that, method, file, cb, options) { + var ff, format; + if (!that.storageAvailable()) { + that.throwErr('Error', 'save', 'no persistent storage available'); + } + // Cb not specified. + if ('undefined' === typeof options && 'object' === typeof cb) { + options = cb; + cb = undefined; + } + else if ('undefined' === typeof cb && 'function' === typeof options) { + cb = options; + options = undefined; + } + validateSaveLoadParameters(that, method, file, cb, options); + options = options || {}; + format = options.format || getExtension(file); + // Try to get the format function based on the extension, + // otherwise try to use the default one. Throws errors. + ff = findFormatFunction(that, method, format); + // Emit save or load. Options can be modified. + that.emit(method.charAt(0) === 's' ? 'save' : 'load', options, { + file: file, + format: format, + cb: cb + }); + ff(that, file, cb, options); + + return that; + } + + /** + * ### findFormatFunction + * + * Returns the requested format function or the default one + * + * Throws errors. + * + * @param {NDDB} that The reference to the current instance + * @param {string} method The name of the method invoking validation + * @param {string} format The requested parameter + * + * @return {function} The requested format function + */ + function findFormatFunction(that, method, format) { + var ff, defFormat; + if (format) ff = that.getFormat(format); + if (ff) { + if (!ff[method]) { + that.throwErr('Error', method, 'format ' + format + ' found, ' + + 'but method ' + method + ' not available'); + } + ff = ff[method]; + } + // Try to get default format, if the extension is not recognized. + if (!ff) { + defFormat = that.getDefaultFormat(); + if (!defFormat) { + that.throwErr('Error', method, 'format ' + format + ' not ' + + 'found and no default format specified'); + } + ff = that.getFormat(defFormat, method); + if (!ff) { + that.throwErr('Error', method, 'format ' + format + ' not ' + + 'found, but default format has no method ' + + method); + } + } + return ff; + } + + /** + * ### validateFormatParameters + * + * Validates the parameters of a call to save, saveSync, load, loadSync + * + * @param {NDDB} that The reference to the current instance + * @param {string|array} method The name/s of format/s + * @param {object} obj The format object + */ + function validateFormatParameters(that, format, obj) { + if ('string' !== typeof format && + !J.isArray(format) && !format.length) { + + that.throwErr('TypeError', 'addFormat', 'format must be ' + + 'a non-empty string or array'); + } + if ('object' !== typeof obj) { + that.throwErr('TypeError', 'addFormat', 'obj must be object'); + } + if (!obj.save && !obj.saveSync) { + that.throwErr('Error', 'addFormat', 'format must ' + + 'at least one save function: sync or async'); + } + if (!obj.load && !obj.loadSync) { + that.throwErr('Error', 'addFormat', 'format must ' + + 'at least one load function: sync or async'); + } + if (obj.save || obj.load) { + if ('function' !== typeof obj.save) { + that.throwErr('TypeError', 'addFormat', + 'save function is not a function'); + } + if ('function' !== typeof obj.load) { + that.throwErr('TypeError', 'addFormat', + 'load function is not a function'); + } + } + if (obj.saveSync || obj.loadSync) { + if ('function' !== typeof obj.saveSync) { + that.throwErr('TypeError', 'addFormat', + 'saveSync function is not a function'); + } + if ('function' !== typeof obj.loadSync) { + that.throwErr('TypeError', 'addFormat', + 'loadSync function is not a function'); + } + } + } + + /** + * # QueryBuilder + * + * MIT Licensed + * + * Helper class for NDDB query selector + * + * --- + */ + + /** + * ## QueryBuilder Constructor + * + * Manages the _select_ queries of NDDB + */ + function QueryBuilder() { + // Creates the query array and internal pointer. + this.reset(); + } + + /** + * ### QueryBuilder.addCondition + * + * Adds a new _select_ condition + * + * @param {string} type. The type of the operation (e.g. 'OR', or 'AND') + * @param {function} filter. The filter callback + */ + QueryBuilder.prototype.addCondition = function(type, filter) { + this.query[this.pointer].push({ + type: type, + cb: filter + }); + }; + + /** + * ### QueryBuilder.addBreak + * + * undocumented + */ + QueryBuilder.prototype.addBreak = function() { + this.pointer++; + this.query[this.pointer] = []; + }; + + /** + * ### QueryBuilder.reset + * + * Resets the current query selection + */ + QueryBuilder.prototype.reset = function() { + this.query = []; + this.pointer = 0; + this.query[this.pointer] = []; + }; + + + function findCallback(obj) { + return obj.cb; + } + + /** + * ### QueryBuilder.get + * + * Builds up the select function + * + * Up to three conditions it builds up a custom function without + * loop. For more than three conditions, a loop is created. + * + * Expressions are evaluated from right to left, so that the last one + * always decides the overall logic value. E.g. : + * + * true AND false OR true => false OR true => TRUE + * true AND true OR false => true OR false => TRUE + * + * @return {function} The select function containing all the specified + * conditions + */ + QueryBuilder.prototype.get = function() { + var line, lineLen, f1, f2, f3, type1, type2; + var query = this.query, pointer = this.pointer; + + // Ready to support nested queries, not yet implemented. + if (pointer === 0) { + line = query[pointer]; + lineLen = line.length; + + if (lineLen === 1) { + return findCallback(line[0]); + } + + else if (lineLen === 2) { + f1 = findCallback(line[0]); + f2 = findCallback(line[1]); + type1 = line[1].type; + + switch (type1) { + case 'OR': + return function(elem) { + if ('undefined' !== typeof f1(elem)) return elem; + if ('undefined' !== typeof f2(elem)) return elem; + }; + case 'AND': + return function(elem) { + if ('undefined' !== typeof f1(elem) && + 'undefined' !== typeof f2(elem)) return elem; + }; + + case 'NOT': + return function(elem) { + if ('undefined' !== typeof f1(elem) && + 'undefined' === typeof f2(elem)) return elem; + }; + } + } + + else if (lineLen === 3) { + f1 = findCallback(line[0]); + f2 = findCallback(line[1]); + f3 = findCallback(line[2]); + type1 = line[1].type; + type2 = line[2].type; + type1 = type1 + '_' + type2; + switch (type1) { + case 'OR_OR': + return function(elem) { + if ('undefined' !== typeof f1(elem)) return elem; + if ('undefined' !== typeof f2(elem)) return elem; + if ('undefined' !== typeof f3(elem)) return elem; + }; + + case 'OR_AND': + return function(elem) { + + if ('undefined' === typeof f3(elem)) return; + if ('undefined' !== typeof f2(elem)) return elem; + if ('undefined' !== typeof f1(elem)) return elem; + }; + + case 'AND_OR': + return function(elem) { + if ('undefined' !== typeof f3(elem)) return elem; + if ('undefined' === typeof f2(elem)) return; + if ('undefined' !== typeof f1(elem)) return elem; + }; + + case 'AND_AND': + return function(elem) { + if ('undefined' === typeof f3(elem)) return; + if ('undefined' === typeof f2(elem)) return; + if ('undefined' !== typeof f1(elem)) return elem; + }; + } + } + + else { + return function(elem) { + var i, f, type, resOK; + var prevType = 'OR', prevResOK = true; + for (i = lineLen-1 ; i > -1 ; i--) { + f = findCallback(line[i]); + type = line[i].type, + resOK = 'undefined' !== typeof f(elem); + + if (type === 'OR') { + // Current condition is TRUE OR + if (resOK) return elem; + } + + // Current condition is FALSE AND + else if (type === 'AND') { + if (!resOK) { + return; + } + // Previous check was an AND or a FALSE OR + else if (prevType === 'OR' && !prevResOK) { + return; + } + } + prevType = type; + // A previous OR is TRUE also if follows a TRUE AND + prevResOK = type === 'AND' ? resOK : resOK || prevResOK; + + } + return elem; + }; + + } + + } + }; + + /** + * # NDDBHashtray + * + * MIT Licensed + * + * Helper class for NDDB hash management + * + * --- + */ + + /** + * ## NDDBHashtray constructor + * + * Creates an hashtray object to manage maps item-hashes + * + * @param {string} The name of the index + * @param {array} The reference to the original database + */ + function NDDBHashtray() { + this.resolve = {}; + } + + NDDBHashtray.prototype.set = function(key, nddbid, hash) { + this.resolve[key + '_' + nddbid] = hash; + }; + + NDDBHashtray.prototype.get = function(key, nddbid) { + return this.resolve[key + '_' + nddbid]; + }; + + NDDBHashtray.prototype.remove = function(key, nddbid) { + delete this.resolve[key + '_' + nddbid]; + }; + + NDDBHashtray.prototype.clear = function() { + this.resolve = {}; + }; + + + // Inheriting from NDDB. + + // function NDDBHash(conf) { + // + // var len = 0; + // + // this.__add = function(key, nddb) { + // this[key] = nddb; + // + // if (conf) nddb.init(conf); + // + // len++; + // }; + // + // this.__size = function() { return len; }; + // + // } + + + + + + + + /** + * # NDDBIndex + * + * MIT Licensed + * + * Helper class for NDDB indexing + * + * --- + */ + + /** + * ## NDDBIndex Constructor + * + * Creates direct access index objects for NDDB + * + * @param {string} The name of the index + * @param {array} The reference to the original database + */ + function NDDBIndex(idx, nddb) { + // The name of the index. + this.idx = idx; + // Reference to the whole nddb database. + this.nddb = nddb; + // Map indexed-item to a position in the original database. + this.resolve = {}; + // List of all keys in `resolve` object. + this.keys = []; + // Map indexed-item to a position in `keys` array (for fast deletion). + this.resolveKeys = {}; + } + + /** + * ### NDDBIndex._add + * + * Adds an item to the index + * + * @param {mixed} idx The id of the item + * @param {number} dbidx The numerical id of the item in the original array + */ + NDDBIndex.prototype._add = function(idx, dbidx) { + this.resolve[idx] = dbidx; + // We add it to the keys array only if it a new index. + // If it is an already existing element, we don't care + // if it changing position in the original db. + if ('undefined' === typeof this.resolveKeys[idx]) { + this.resolveKeys[idx] = this.keys.length; + this.keys.push('' + idx); + } + }; + + /** + * ### NDDBIndex._remove + * + * Removes an item from index + * + * @param {mixed} idx The id to remove from the index + */ + NDDBIndex.prototype._remove = function(idx) { + delete this.resolve[idx]; + this.keys.splice(this.resolveKeys[idx], 1); + delete this.resolveKeys[idx]; + }; + + /** + * ### NDDBIndex.size + * + * Returns the size of the index + * + * @return {number} The number of elements in the index + */ + NDDBIndex.prototype.size = function() { + return this.keys.length; + }; + + /** + * ### NDDBIndex.get + * + * Gets the entry from database with the given id + * + * @param {mixed} idx The id of the item to get + * @return {object|boolean} The indexed entry, or FALSE if index is invalid + * + * @see NDDB.index + * @see NDDBIndex.remove + * @see NDDBIndex.update + */ + NDDBIndex.prototype.get = function(idx) { + if ('undefined' === typeof this.resolve[idx]) return false; + return this.nddb.db[this.resolve[idx]]; + }; + + + /** + * ### NDDBIndex.remove + * + * Removes and entry from the database with the given id and returns it + * + * @param {mixed} idx The id of item to remove + * + * @return {object|boolean} The removed item, or FALSE if the + * index is invalid or if the object could not be removed, + * e.g. if a on('remove') callback returned FALSE. + * + * @see NDDB.index + * @see NDDB.emit + * @see NDDBIndex.get + * @see NDDBIndex.update + */ + NDDBIndex.prototype.remove = function(idx) { + var o, dbidx, res; + dbidx = this.resolve[idx]; + if ('undefined' === typeof dbidx) return false; + o = this.nddb.db[dbidx]; + if ('undefined' === typeof o) return false; + res = this.nddb.emit('remove', o, dbidx); + if (res === false) return false; + this.nddb.db.splice(dbidx, 1); + this._remove(idx); + this.nddb._autoUpdate(); + return o; + }; + + // ### NDDBIndex.pop + // @deprecated + NDDBIndex.prototype.pop = NDDBIndex.prototype.remove; + + /** + * ### NDDBIndex.update + * + * Updates an entry with the given id + * + * @param {mixed} idx The id of item to update + * + * @return {object|boolean} The updated item, or FALSE if + * index is invalid, or a callback on('update') returned FALSE. + * + * @see NDDB.index + * @see NDDBIndex.get + * @see NDDBIndex.remove + */ + NDDBIndex.prototype.update = function(idx, update) { + var o, dbidx, nddb, res; + if ('undefined' === typeof update) return false; + dbidx = this.resolve[idx]; + if ('undefined' === typeof dbidx) return false; + nddb = this.nddb; + o = nddb.db[dbidx]; + res = nddb.emit('update', o, update, dbidx); + if (res === false) return false; + J.mixin(o, update); + // We do indexes separately from the other components of _autoUpdate + // to avoid looping through all the other elements that are unchanged. + if (nddb.__update.indexes) { + nddb._indexIt(o, dbidx, idx); + nddb._hashIt(o); + nddb._viewIt(o); + } + nddb._autoUpdate({indexes: false}); + return o; + }; + + /** + * ### NDDBIndex.getAllKeys + * + * Returns the list of all keys in the index + * + * @return {array} The array of alphanumeric keys in the index + * + * @see NDDBIndex.getAllKeyElements + */ + NDDBIndex.prototype.getAllKeys = function() { + return this.keys.slice(0); + }; + + /** + * ### NDDBIndex.getAllKeyElements + * + * Returns all the elements indexed by their key in one object + * + * @return {object} The object of key-elements + * + * @see NDDBIndex.getAllKeys + */ + NDDBIndex.prototype.getAllKeyElements = function() { + var out, idx, i, len; + out = {}; + i = -1, len = this.keys.length; + for ( ; ++i < len ; ) { + idx = this.keys[i]; + out[idx] = this.nddb.db[this.resolve[idx]]; + } + return out; + }; + +})(); + +/** + * # nodeGame: Social Experiments in the Browser + * Copyright(c) 2015 Stefano Balietti + * MIT Licensed + * + * nodeGame is a free, open source, event-driven javascript framework, + * for real-time multiplayer games in the browser. + */ +(function(window) { + if ('undefined' !== typeof window.node) { + throw new Error('nodegame-client: a global node variable is already ' + + 'defined. Aborting...'); + } + + // Defining an empty node object. Will be overwritten later on. + var node = window.node = {}; + + if ('undefined' !== typeof JSUS) node.JSUS = JSUS; + if ('undefined' !== typeof NDDB) node.NDDB = NDDB; + if ('undefined' !== typeof store) node.store = store; + node.support = JSUS.compatibility(); + + // Auto-Generated. + node.version = '7.1.0'; + +})(window); + +/** + * # Variables + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * `nodeGame` variables and constants module + */ +(function(node) { + + "use strict"; + + // ## Constants + + var k; + k = node.constants = {}; + + /** + * ### node.constants.nodename + * + * Default nodename if none is specified + * + * @see node.setup.nodename + */ + k.nodename = 'ng'; + + /** + * ### node.constants.verbosity_levels + * + * ALWAYS, ERR, WARN, INFO, DEBUG + */ + k.verbosity_levels = { + ALWAYS: -Number.MAX_VALUE, + error: -1, + warn: 0, + info: 1, + silly: 10, + debug: 100, + NEVER: Number.MAX_VALUE + }; + + /** + * ### node.constants.actions + * + * Collection of available nodeGame actions + * + * The action adds an initial semantic meaning to the + * message. It specify the nature of requests + * "Why the message was sent?" + * + * Semantics: + * + * - SET: Store / changes the value of a property in the receiver of the msg + * - GET: Asks the value value of a property to the receiver of the msg + * - SAY: Announces a change of state or property in the sender of the msg + */ + k.action = {}; + + k.action.SET = 'set'; + k.action.GET = 'get'; + k.action.SAY = 'say'; + + /** + * ### node.constants.target + * + * Collection of available nodeGame targets + * + * The target adds an additional level of semantic + * for the message, and specifies the nature of the + * information carried in the message. + * + * It answers the question: "What is the content of the message?" + */ + k.target = {}; + + // #### target.DATA + // Generic identifier for any type of data + k.target.DATA = 'DATA'; + + // #### target.HI + // A client is connecting for the first time + k.target.HI = 'HI'; + + // #### target.PCONNECT + // A new client just connected to the player endpoint + k.target.PCONNECT = 'PCONNECT'; + + // #### target.PDISCONNECT + // A client that just disconnected from the player endpoint + k.target.PDISCONNECT = 'PDISCONNECT'; + + // #### target.PRECONNECT + // A previously disconnected client just re-connected to the player endpoint + k.target.PRECONNECT = 'PRECONNECT'; + + // #### target.MCONNECT + // A client that just connected to the admin (monitor) endpoint + k.target.MCONNECT = 'MCONNECT'; + + // #### target.MDISCONNECT + // A client just disconnected from the admin (monitor) endpoint + k.target.MDISCONNECT = 'MDISCONNECT'; + + // #### target.MRECONNECT + // A previously disconnected client just re-connected to the admin endpoint + k.target.MRECONNECT = 'MRECONNECT'; + + // #### target.PLIST + // The list of clients connected to the player endpoint was updated + k.target.PLIST = 'PLIST'; + + // #### target.MLIST + // The list of clients connected to the admin (monitor) endpoint was updated + k.target.MLIST = 'MLIST'; + + // #### target.PLAYER_UPDATE + // A client updates his Player object + k.target.PLAYER_UPDATE = 'PLAYER_UPDATE'; + + // #### target.REDIRECT + // Redirects a client to a new uri + k.target.REDIRECT = 'REDIRECT'; + + // #### target.LANG + // Requests language information + k.target.LANG = 'LANG'; + + // #### target.SETUP + // Asks a client update its configuration + k.target.SETUP = 'SETUP'; + + // #### target.GAMECOMMAND + // Ask a client to start/pause/stop/resume the game + k.target.GAMECOMMAND = 'GAMECOMMAND'; + + // #### target.SERVERCOMMAND + // Ask a server to execute a command + k.target.SERVERCOMMAND = 'SERVERCOMMAND'; + + // #### target.ALERT + // Displays an alert message in the receiving client (if in the browser) + k.target.ALERT = 'ALERT'; + + // #### target.LOG + // A generic log message used to send info to the server + // @see NodeGameClient.remoteVerbosity + k.target.LOG = 'LOG'; + + // #### target.BYE + // Force disconnection upon reception. + k.target.BYE = 'BYE'; + + // #### target.SESSION + // Stores the value of the message in the session. + k.target.SESSION = 'SESSION'; + + //#### not used targets (for future development) + + + k.target.JOIN = 'JOIN'; // Asks a client to join another channel + + k.target.TXT = 'TXT'; // Text msg + + k.target.ACK = 'ACK'; // A reliable msg was received correctly + + k.target.WARN = 'WARN'; // To do. + k.target.ERR = 'ERR'; // To do. + + // Old targets. + + // #### target.STAGE + // A client notifies his own stage + // k.target.STAGE = 'STAGE'; + + // #### target.STAGE_LEVEL + // A client notifies his own stage level + // k.target.STAGE_LEVEL = 'STAGE_LEVEL'; + + + // ### node.constants.gamecommands + k.gamecommands = { + start: 'start', + pause: 'pause', + resume: 'resume', + stop: 'stop', + restart: 'restart', + step: 'step', + push_step: 'push_step', + goto_step: 'goto_step', + clear_buffer: 'clear_buffer', + erase_buffer: 'erase_buffer' + }; + + /** + * ### Direction + * + * Distiguishes between incoming and outgoing messages + * + * - node.constants.IN + * - node.constants.OUT + */ + k.IN = 'in.'; + k.OUT = 'out.'; + + /** + * ### node.constants.stateLevels + * + * Levels associated with the states of the nodeGame engine. + */ + k.stateLevels = { + UNINITIALIZED: 0, // creating the game object + STARTING: 1, // constructor executed + INITIALIZING: 2, // calling game's init + INITIALIZED: 5, // init executed + STAGE_INIT: 10, // calling stage's init + STEP_INIT: 20, // calling step's init + PLAYING_STEP: 30, // executing step + STAGE_EXIT: 50, // calling stage's cleanup + STEP_EXIT: 60, // calling step's clenaup + FINISHING: 70, // calling game's gameover + GAMEOVER: 100, // game complete + RUNTIME_ERROR: -1 + }; + + /** + * ### node.constants.stageLevels + * + * Levels associated with the states of the stages of a game. + */ + k.stageLevels = { + + UNINITIALIZED: 0, // Constructor called. + + INITIALIZING: 1, // Executing init. + + INITIALIZED: 5, // Init executed. + + LOADING_FRAME: 20, // A frame is being loaded (only in browser). + + FRAME_LOADED: 25, // The frame has been loaded (only in browser). + + EXECUTING_CALLBACK: 30, // Executing the stage callback. + + CALLBACK_EXECUTED: 40, // Stage callback executed. + + LOADED: 45, // Both GameWindow loaded and cb executed. + + PLAYING: 50, // Player playing. + + PAUSING: 55, // TODO: to be removed? + + PAUSED: 60, // TODO: to be removed? + + RESUMING: 65, // TODO: to be removed? + + RESUMED: 70, // TODO: to be removed? + + DONE_CALLED: 80, // Done is called, + // will be asynchronously evaluated. + + GETTING_DONE: 90, // Done is being called, + // and the step rule evaluated. + + DONE: 100, // Player completed the stage + + EXITING: 110, // Cleanup function being called (if found) + }; + + /** + * ### node.constants.windowLevels + * + * Levels associated with the loading of the GameWindow object. + * + * @see GameWindow + * @see GameWindow.state + */ + k.windowLevels = { + UNINITIALIZED: 0, // GameWindow constructor called + INITIALIZING: 1, // Executing init. + INITIALIZED: 5, // Init executed. + LOADING: 30, // Loading a new Frame. + LOADED: 40 // Frame Loaded. + }; + + /** + * ### node.constants.screenState + * + * Levels describing whether the user can interact with the screen. + * + * @see GameWindow.screenState + * @see GameWindow.lockFrame + */ + k.screenLevels = { + ACTIVE: 1, // User can interact with screen (if LOADED) + UNLOCKING: -1, // The screen is about to be unlocked. + LOCKING: -2, // The screen is about to be locked. + LOCKED: -3 // The screen is locked. + }; + + /** + * ### node.constants.UNDEFINED_PLAYER + * + * Undefined player ID + */ + k.UNDEFINED_PLAYER = -1; + + /** + * ### node.constants.UNAUTH_PLAYER + * + * Unauthorized player ID + * + * This string is returned by the server if authentication fails. + */ + k.UNAUTH_PLAYER = 'unautorized_player'; + + + /** + * ### node.constants.publishLevels + * + * The level of updates that the server receives about the state of a game + * + * - ALL: all stateLevel, stageLevel, and gameStage updates + * - MOST: all stageLevel and gameStage updates + * - REGULAR: only stageLevel PLAYING and DONE, and all gameStage updates + * - FEW: only gameStage updates (might not work for multiplayer games) + * - NONE: no updates. The same as observer. + */ + k.publishLevels = { + ALL: 4, + MOST: 3, + REGULAR: 2, + FEW: 1, + NONE: 0 + }; + +})('undefined' != typeof node ? node : module.exports); + +/** + * # Stepping Rules + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * Collections of rules to determine whether the game should step forward. + */ +(function(exports, parent) { + + "use strict"; + + exports.stepRules = {}; + + // Renaming parent to node, so that functions can be executed + // context-less in the browser too. + var node = parent; + + // Important! Cannot define DONE = node.constants.stageLevels.DONE; + // It is not defined on browsers then. + + // ## SOLO + // + // Always steps when current step is DONE + // + exports.stepRules.SOLO = function(stage, myStageLevel, pl, game) { + return myStageLevel === node.constants.stageLevels.DONE; + }; + + // ## SOLO_STEP + // + // Steps when current step is DONE, but only if it is not last step in stage + // + // When the last step in current stage is done, then it waits + // for an explicit step command. + // + exports.stepRules.SOLO_STEP = function(stage, myStageLevel, pl, game) { + // If next step is going to be a new stage, then wait. + if (game.plot.stepsToNextStage(stage, true) === 1) return false; + else return myStageLevel === node.constants.stageLevels.DONE; + }; + + // ## WAIT + // + // Always waits for explicit step command + // + exports.stepRules.WAIT = function(stage, myStageLevel, pl, game) { + return false; + }; + + // ## SYNC_STEP + // + // Steps when current step is DONE for all clients (including itself) + // + // If no other clients are connected, then it behaves like SOLO. + // + exports.stepRules.SYNC_STEP = function(stage, myStageLevel, pl, game) { + return myStageLevel === node.constants.stageLevels.DONE && + pl.isStepDone(stage); + }; + + // ## SYNC_STAGE + // + // Like SOLO, but in the last step of a stage behaves like SYNC_STEP + // + // If no other clients are connected, then it behaves like SOLO also + // in the last step. + // + // Important: it assumes that the number of steps in current + // stage is the same in all clients (including this one). + // + exports.stepRules.SYNC_STAGE = function(stage, myStageLevel, pl, game) { + var iamdone; + iamdone = myStageLevel === node.constants.stageLevels.DONE; + // If next step is going to be a new stage, wait for others. + if (game.plot.stepsToNextStage(stage) > 1) return iamdone; + else return iamdone && pl.isStepDone(stage, 'STAGE_UPTO'); + }; + + // ## OTHERS_SYNC_STEP + // + // Like SYNC_STEP, but does not look at own stage level + // + // If no other clients are connected, then it behaves like WAIT. + // + exports.stepRules.OTHERS_SYNC_STEP = function(stage, myStageLevel, pl) { + if (!pl.size()) return false; + stage = pl.first().stage; + return pl.arePlayersSync(stage, node.constants.stageLevels.DONE, + 'EXACT'); + }; + + // ## OTHERS_SYNC_STAGE + // + // Like SYNC_STAGE, but does not look at own stage level + // + // If no other clients are connected, then it behaves like WAIT. + // + // Important: it assumes that the number of steps in current + // stage is the same in all clients (including this one). + // + exports.stepRules.OTHERS_SYNC_STAGE = function(stage, myStageLevel, pl, + game) { + + var nSteps; + if (!pl.size()) return false; + stage = pl.first().stage; + nSteps = game.plot.stepsToNextStage(stage); + // Manual clone in case there are more steps to go. + if (nSteps !== 1) { + stage = { + stage: stage.stage, + step: stage.step + (nSteps - 1), + round: stage.round + }; + } + return pl.arePlayersSync(stage, node.constants.stageLevels.DONE, + 'EXACT', true); + }; + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports + , 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # ErrorManager + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * Handles runtime errors + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + var J = parent.JSUS; + + parent.ErrorManager = ErrorManager; + + /** + * ## ErrorManager constructor + * + * Creates a new instance of ErrorManager + * + * @param {NodeGameClient} node Reference to the active node object. + */ + function ErrorManager(node) { + + /** + * ### ErrorManager.lastError + * + * Reference to the last error occurred + */ + this.lastError = null; + + this.init(node); + } + + // ## ErrorManager methods + + /** + * ### ErrorManager.init + * + * Starts catching run-time errors + * + * Only active in the browser's window. + * In node.js, the ServerNode Error Manager is active. + * + * @param {NodeGameClient} node Reference to the active node object. + */ + ErrorManager.prototype.init = function(node) { + var that; + that = this; + if (!J.isNodeJS()) { + window.onerror = function(msg, url, lineno, colno, error) { + var str; + msg = node.game.getCurrentGameStage().toString() + + '@' + J.getTime() + '> ' + + url + ' ' + lineno + ',' + colno + ': ' + msg; + if (error) msg + ' - ' + JSON.stringify(error); + that.lastError = msg; + node.err(msg); + if (node.debug) { + W.init({ waitScreen: true }); + str = 'DEBUG mode: client-side error ' + + 'detected.

'; + str += msg; + str += '

Open the DevTools in your browser ' + + 'for details.
' + + 'This message will not be shown in production mode.'; + W.lockScreen(str); + } + return !node.debug; + }; + } + }; + + +// ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # EventEmitter + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * Event emitter engine for `nodeGame` + * + * Keeps a register of events listeners. + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + var J = parent.JSUS, + NDDB = parent.NDDB, + GameStage = parent.GameStage; + + exports.EventEmitter = EventEmitter; + exports.EventEmitterManager = EventEmitterManager; + + /** + * ## EventEmitter constructor + * + * Creates a new instance of EventEmitter + */ + function EventEmitter(name, node) { + if ('string' !== typeof name) { + throw new TypeError('EventEmitter constructor: name must be ' + + 'string. Found: ' + name); + } + + this.node = node; + + // ## Public properties + + this.name = name; + + /** + * ### EventEmitter.listeners + * + * Event listeners collection + */ + this.events = {}; + + /** + * ## EventEmitter.recordChanges + * + * If TRUE, keeps tracks of addition and deletion of listeners + * + * @see EventEmitter.changes + */ + this.recordChanges = false; + + /** + * ## EventEmitter.changes + * + * If TRUE, keeps tracks of addition and deletion of listeners + * + * @see EventEmitter.recordChanges + */ + this.changes = { + added: [], + removed: [] + }; + + /** + * ## EventEmitter.labels + * + * List of labels associated to an event listener + * + * @see EventEmitter.on + */ + this.labels = {}; + + /** + * ### EventEmitter.history + * + * Database of emitted events + * + * @experimental + * + * @see NDDB + * @see EventEmitter.EventHistory + * @see EventEmitter.store + */ + this.history = new EventHistory(this.node); + } + + // ## EventEmitter methods + + /** + * ### EventEmitter.on + * + * Registers a callback function for an event (event listener) + * + * @param {string} type The event name + * @param {function} listener The function to emit + * @param {string|number} label Optional. If set, it flags the listener with + * the property .__ngid = label. It will be then possible to remove + * the listener using the label + * + * @see EventEmitter.off + */ + EventEmitter.prototype.on = function(type, listener, label) { + if ('string' !== typeof type || type === '') { + throw new TypeError('EventEmitter.on: type must be a non-empty ' + + 'string. Found: ' + type); + } + if ('function' !== typeof listener) { + throw new TypeError('EventEmitter.on: listener must be function.' + + 'Found: ' + listener); + } + if (label) checkAndAddLabel(this, listener, label, 'on'); + + if (!this.events[type]) { + // Optimize the case of one listener. + // Don't need the extra array object. + this.events[type] = listener; + } + else if (typeof this.events[type] === 'object') { + // If we've already got an array, just append. + this.events[type].push(listener); + } + else { + // Adding the second element, need to change to array. + this.events[type] = [this.events[type], listener]; + } + + // Storing changes if necessary. + if (this.recordChanges) { + this.changes.added.push({type: type, listener: listener}); + } + + this.node.silly(this.name + '.on: added: ' + type); + }; + + /** + * ### EventEmitter.once + * + * Registers an event listener that will be removed after its first call + * + * @param {string} event The name of the event + * @param {function} listener The callback function + * @param {string|number} label Optional. If set, it flags the listener with + * the property .__ngid = label. It will be then possible to remove + * the listener using the label + * + * @see EventEmitter.on + * @see EventEmitter.off + */ + EventEmitter.prototype.once = function(type, listener, label) { + var that = this; + if (!label) label = J.uniqueKey(this.labels, type); + function g() { + var i, len, args; + args = []; + i = -1, len = arguments.length; + for ( ; ++i < len ; ) { + args[i] = arguments[i]; + } + that.off(type, label); + listener.apply(that.node.game, args); + } + this.on(type, g, label); + }; + + /** + * ### EventEmitter.emit + * + * Fires all the listeners associated with an event + * + * The first parameter is the name of the event as _string_, + * followed by any number of parameters that will be passed to the + * callback. + * + * Return values of each callback are aggregated and returned as + * an array. If the array contains less than 2 elements, only + * element or _undefined_ is returned. + * + * @return {mixed} The return value of the callback/s + */ + EventEmitter.prototype.emit = function() { + var handler, len, args, i, listeners, type, ctx, node; + var res, tmpRes; + + type = arguments[0]; + handler = this.events[type]; + if ('undefined' === typeof handler) return; + + node = this.node; + ctx = node.game; + + // Useful for debugging. + if (this.node.conf.events && this.node.conf.events.dumpEvents) { + this.node.info('F: ' + this.name + ': ' + type); + } + + if ('function' === typeof handler) { + + switch (arguments.length) { + // fast cases + case 1: + res = handler.call(ctx); + break; + case 2: + res = handler.call(ctx, arguments[1]); + break; + case 3: + res = handler.call(ctx, arguments[1], arguments[2]); + break; + case 4: + res = handler.call(ctx, arguments[1], arguments[2], + arguments[3]); + break; + + default: + len = arguments.length; + args = new Array(len - 1); + for (i = 1; i < len; i++) { + args[i - 1] = arguments[i]; + } + res = handler.apply(ctx, args); + } + } + else if (handler && 'object' === typeof handler) { + len = arguments.length; + args = new Array(len - 1); + for (i = 1; i < len; i++) { + args[i - 1] = arguments[i]; + } + listeners = handler.slice(); + len = listeners.length; + // If more than one event listener is registered, + // we will return an array. + res = []; + for (i = 0; i < len; i++) { + tmpRes = listeners[i].apply(node.game, args); + if ('undefined' !== typeof tmpRes) + res.push(tmpRes); + } + // If less than 2 listeners returned a value, compact the result. + if (!res.length) res = undefined; + else if (res.length === 1) res = res[0]; + } + + // Log the event into node.history object, if present. + if (node.conf && node.conf.events && + node.conf.events.history) { + + len = arguments.length; + args = new Array(len); + for (i = -1 ; ++i < len ; ) { + args[i] = arguments[i]; + } + + this.history.insert({ + stage: node.game.getCurrentGameStage(), + args: args + }); + } + + return res; + }; + + /** + * ### EventEmitter.emitAsync + * + * Fires all the listeners associated with an event asynchronously + * + * The event must be already existing, cannot be added after the call. + * + * Unlike normal emit, it does not return a value. + * + * @see EventEmitter.emit + */ + EventEmitter.prototype.emitAsync = function() { + var that, len, args, i; + var arg1, arg2, arg3; + arg1 = arguments[0]; + + if (!this.events[arg1]) return; + + len = arguments.length; + that = this; + + // The arguments object must not be passed or leaked anywhere. + // Therefore, we recreate an args array here. We have a different + // timeout in a different branch for optimization. + switch(len) { + + case 1: + setTimeout(function() { that.emit(arg1); }, 0); + break; + case 2: + arg2 = arguments[1]; + setTimeout(function() { that.emit(arg1, arg2); }, 0); + break; + case 3: + arg2 = arguments[1], arg3 = arguments[2]; + setTimeout(function() { that.emit(arg1, arg2, arg3); }, 0); + break; + default: + args = new Array(len); + for (i = -1 ; ++i < len ; ) { + args[i] = arguments[i]; + } + setTimeout(function() { that.emit.apply(that, args); }, 0); + } + }; + + /** + * ### EventEmitter.off || remove + * + * Deregisters one or multiple event listeners + * + * If the listener is specified as a string, the first function + * with either the name or the label equal to listener will be removed. + * + * @param {string} type The event name + * @param {mixed} listener Optional. The specific function + * to deregister, its name, the label as specified during insertion, + * or undefined to remove all listeners + * + * @return {array} The array of removed listener/s + * + * @see node.on + */ + EventEmitter.prototype.remove = EventEmitter.prototype.off = + function(type, listener) { + + var listeners, len, i, node, found, oneFound, removed; + + removed = []; + node = this.node; + + if ('string' !== typeof type) { + throw new TypeError('EventEmitter.remove (' + this.name + + '): type must be string. Found: ' + type); + } + + if (listener && + ('function' !== typeof listener && 'string' !== typeof listener)) { + throw new TypeError('EventEmitter.remove (' + this.name + + '): listener must be function, string, or ' + + 'undefined. Found: ' + listener); + } + + if ('string' === typeof listener && listener.trim() === '') { + throw new Error('EventEmitter.remove (' + this.name + '): ' + + 'listener cannot be an empty string'); + } + + if (this.events[type]) { + + if (!listener) { + oneFound = true; + i = -1, len = this.events[type].length; + for ( ; ++i < len ; ) { + removed.push(this.events[type][i]); + } + // Null instead of delete for optimization. + this.events[type] = null; + } + + else { + // Handling multiple cases: + // this.events[type] can be array or function, + // and listener can be function or string. + + if ('function' === typeof this.events[type]) { + + if ('function' === typeof listener) { + if (listener == this.events[type]) oneFound = true; + } + else { + // String. + if (listener === this.events[type].__ngid) { + this.labels[listener] = null; + oneFound = true; + } + else if (listener === J.funcName(this.events[type])) { + oneFound = true; + } + } + + if (oneFound) { + removed.push(this.events[type]); + // Null instead of delete for optimization. + this.events[type] = null; + } + } + // this.events[type] is an array. + else { + listeners = this.events[type]; + len = listeners.length; + for (i = 0; i < len; i++) { + found = false; + if ('function' === typeof listener) { + if (listeners[i] == listener) found = true; + } + else { + // String. + if (listener === listeners[i].__ngid) { + this.labels[listener] = null; + found = true; + } + else if (listener === J.funcName(listeners[i])) { + found = true; + } + } + + if (found) { + oneFound = true; + removed.push(listeners[i]); + if (len === 1) { + // Null instead of delete for optimization. + this.events[type] = null; + } + else { + listeners.splice(i, 1); + // Update indexes, + // because array size has changed. + len--; + i--; + } + } + } + } + } + } + + if (oneFound) { + // Storing changes if necessary. + if (this.recordChanges) { + i = -1, len = removed.length; + for ( ; ++i < len ; ) { + this.changes.removed.push({ + type: type, + listener: removed[i] + }); + } + } + node.silly('ee.' + this.name + ' removed listener: ' + type); + } + else { + node.warn('EventEmitter.remove (' + this.name + '): requested ' + + 'listener was not found for event ' + type); + } + + return removed; + }; + + /** + * ### EventEmitter.clear + * + * Removes all registered event listeners + * + * Clears the labels and store changes, if requested + * + * @see EventEmitter.labels + * @see EventEmitter.setRecordChanges + */ + EventEmitter.prototype.clear = function() { + var event, i, len; + if (this.recordChanges) { + for (event in this.events) { + if ('function' === typeof this.events[event]) { + this.changes.removed.push({ + type: event, + listener: this.events[event] + }); + } + else if (J.isArray(this.events[event])) { + i = -1, len = this.events[event].length; + for ( ; ++i < len ; ) { + this.changes.removed.push({ + type: event, + listener: this.events[event][i] + }); + } + } + } + } + this.events = {}; + this.labels = {}; + }; + + /** + * ### EventEmitter.size + * + * Returns the number of registered events / event listeners + * + * @param {mixed} Optional. Modifier controlling the return value + * + * @return {number} Depending on the value of the modifier returns + * the total number of: + * + * - Not set: events registered + * - String: event listeners for the specified event + * - true: event listeners for all events + */ + EventEmitter.prototype.size = function(mod) { + var count; + count = 0; + if (!mod) { + for (mod in this.events) { + if (this.events.hasOwnProperty(mod)) { + // Not null (delete events). + if (this.events[mod]) count++; + } + } + return count; + } + if ('string' === typeof mod) { + if (!this.events[mod]) return 0; + if ('function' === typeof this.events[mod]) return 1; + return this.events[mod].length; + } + for (mod in this.events) { + count += this.size(mod); + } + return count; + }; + + /** + * ### EventEmitter.printAll + * + * Prints to console all the registered functions + * + * @return {number} The total number of registered functions + */ + EventEmitter.prototype.printAll = function() { + var i, len, totalLen, str; + totalLen = 0, str = ''; + for (i in this.events) { + if (this.events.hasOwnProperty(i)) { + len = this.size(i); + str += i + ': ' + len + "\n"; + totalLen += len; + } + } + console.log('[' + this.name + '] ' + totalLen + ' listener/s.'); + if (str) console.log(str); + return totalLen; + }; + + /** + * ### EventEmitter.getChanges + * + * Returns the list of added and removed event listeners + * + * @param {boolean} clear Optional. If TRUE, the list of current changes + * is cleared. Default FALSE + * + * @return {object} Object containing list of additions and deletions, + * or null if no changes have been recorded + */ + EventEmitter.prototype.getChanges = function(clear) { + var changes; + if (this.changes.added.length || this.changes.removed.length) { + changes = this.changes; + if (clear) { + this.changes = { + added: [], + removed: [] + }; + } + } + return changes; + }; + + /** + * ### EventEmitter.setRecordChanges + * + * Sets the value of recordChanges and returns it + * + * If called with undefined, just returns current value. + * + * @param {boolean} record If TRUE, starts recording changes. Default FALSE + * + * @return {boolean} Current value of recordChanges + * + * @see EventEmitter.recordChanges + */ + EventEmitter.prototype.setRecordChanges = function(record) { + if ('boolean' === typeof record) this.recordChanges = record; + else if ('undefined' !== typeof record) { + throw new TypeError('EventEmitter.setRecordChanged: record must ' + + 'be boolean or undefined. Found: ' + record); + } + return this.recordChanges; + }; + + // ### Helper functions + + /** + * #### checkAndAddLabel + * + * If label is valid, adds it to the labels object and marks the listener + * + * @param {EventEmitter} that The instance of event emitter + * @param {function} listener The listener function + * @param {string|number} label The label to check + * @param {string} method The invoking method (on or once) + */ + function checkAndAddLabel(that, listener, label, method) { + if ('string' === typeof label || 'number' === typeof label) { + if (that.labels[label]) { + throw new Error('EventEmitter.' + method + + ': label is not unique: ' + label); + } + that.labels[label] = true; + listener.__ngid = '' + label; + } + else { + throw new TypeError('EventEmitter.' + method + ': label must be ' + + 'string or undefined. Found: ' + label); + } + } + + + /** + * ## EventEmitterManager constructor + * + * @param {NodeGameClient} node A reference to the node object + */ + function EventEmitterManager(node) { + + this.node = node; + + this.ee = {}; + + this.createEE('ng'); + this.createEE('game'); + this.createEE('stage'); + this.createEE('step'); + } + + // ## EventEmitterManager methods + + /** + * ### EventEmitterManager.createEE + * + * Creates and registers an event emitter + * + * A double reference is added to _this.ee_ and to _this_. + * + * @param {string} name The name of the event emitter + * + * @return {EventEmitter} A reference to the newly created event emitter + * + * @see EventEmitter constructor + */ + EventEmitterManager.prototype.createEE = function(name) { + this.ee[name] = new EventEmitter(name, this.node); + this[name] = this.ee[name]; + return this.ee[name]; + }; + + /** + * ### EventEmitterManager.destroyEE + * + * Removes an existing event emitter + * + * @param {string} name The name of the event emitter + * + * @return {boolean} TRUE, on success + * + * @see EventEmitterManager.createEE + */ + EventEmitterManager.prototype.destroyEE = function(name) { + if ('string' !== typeof name) { + throw new TypeError('EventEmitterManager.destroyEE: name must be ' + + 'string. Found: ' + name); + } + if (!this.ee[name]) return false; + delete this[name]; + delete this.ee[name]; + return true; + }; + + /** + * ### EventEmitterManager.clear + * + * Removes all registered event listeners from all event emitters + */ + EventEmitterManager.prototype.clear = function() { + this.ng.clear(); + this.game.clear(); + this.stage.clear(); + this.step.clear(); + }; + + /** + * ### EventEmitterManager.emit + * + * Emits an event on all registered event emitters + * + * Accepts a variable number of input parameters. + * + * @param {string} eventName The name of the event + * + * @return {mixed} The values returned by all fired event listeners + * + * @see EventEmitterManager.emit + */ + EventEmitterManager.prototype.emit = function(eventName) { + var i, tmpRes, res, args, len, ees; + + if ('string' !== typeof eventName) { + throw new TypeError( + 'EventEmitterManager.emit: eventName must be string. Found: ' + + eventName); + } + res = []; + + len = arguments.length; + + // The scope might `node` if this method is invoked from `node.emit`. + ees = this.ee || this.events.ee; + + // The arguments object must not be passed or leaked anywhere. + switch(len) { + + case 1: + tmpRes = ees.ng.emit(eventName); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.game.emit(eventName); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.stage.emit(eventName); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.step.emit(eventName); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + break; + case 2: + tmpRes = ees.ng.emit(eventName, arguments[1]); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.game.emit(eventName, arguments[1]); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.stage.emit(eventName, arguments[1]); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.step.emit(eventName, arguments[1]); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + break; + case 3: + tmpRes = ees.ng.emit(eventName, arguments[1], arguments[2]); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.game.emit(eventName, arguments[1], arguments[2]); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.stage.emit(eventName, arguments[1], arguments[2]); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.step.emit(eventName, arguments[1], arguments[2]); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + break; + default: + args = new Array(len); + for (i = -1 ; ++i < len ; ) { + args[i] = arguments[i]; + } + tmpRes = ees.ng.emit.apply(ees.ng, args); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.game.emit.apply(ees.game, args); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.stage.emit.apply(ees.stage, args); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + tmpRes = ees.step.emit.apply(ees.step, args); + if ('undefined' !== typeof tmpRes) res.push(tmpRes); + } + + // If there are less than 2 elements, unpack the array. + // res[0] is either undefined or some value. + return res.length < 2 ? res[0] : res; + }; + + /** + * ### EventEmitterManager.emitAsync + * + * Emits an event on all registered event emitters asynchrounsly + * + * Accepts a variable number of input parameters. + * + * @param {string} eventName The name of the event + * + * @see EventEmitterManager.emit + */ + EventEmitterManager.prototype.emitAsync = function(eventName) { + var i, len, args, ees; + + if ('string' !== typeof eventName) { + throw new TypeError( + 'EventEmitterManager.emit: eventName must be string. Found: ' + + eventName); + } + + len = arguments.length; + + // The scope might `node` if this method is invoked from `node.emit`. + ees = this.ee || this.events.ee; + + // The arguments object must not be passed or leaked anywhere. + switch(len) { + + case 1: + ees.ng.emitAsync(eventName); + ees.game.emitAsync(eventName); + ees.stage.emitAsync(eventName); + ees.step.emitAsync(eventName); + break; + case 2: + ees.ng.emitAsync(eventName, arguments[1]); + ees.game.emitAsync(eventName, arguments[1]); + ees.stage.emitAsync(eventName, arguments[1]); + ees.step.emitAsync(eventName, arguments[1]); + break; + case 3: + ees.ng.emitAsync(eventName, arguments[1], arguments[2]); + ees.game.emitAsync(eventName, arguments[1], arguments[2]); + ees.stage.emitAsync(eventName, arguments[1], arguments[2]); + ees.step.emitAsync(eventName, arguments[1], arguments[2]); + break; + default: + args = new Array(len); + for (i = -1 ; ++i < len ; ) { + args[i] = arguments[i]; + } + ees.ng.emitAsync.apply(ees.ng, args); + ees.game.emitAsync.apply(ees.game, args); + ees.stage.emitAsync.apply(ees.stage, args); + ees.step.emitAsync.apply(ees.step, args); + } + }; + + /** + * ### EventEmitterManager.remove + * + * Removes an event / event listener from all registered event emitters + * + * @param {string} eventName The name of the event + * @param {function|string} listener Optional A reference to the + * function to remove, or its name + * + * @return {object} Object containing removed listeners by event emitter + */ + EventEmitterManager.prototype.remove = function(eventName, listener) { + var res; + if ('string' !== typeof eventName) { + throw new TypeError('EventEmitterManager.remove: eventName ' + + 'must be string. Found: ' + eventName); + } + if (listener && + ('function' !== typeof listener && 'string' !== typeof listener)) { + throw new TypeError('EventEmitter.remove (' + this.name + + '): listener must be function, string, or ' + + 'undefined. Found: ' + listener); + } + res = {}; + res.ng = this.ng.remove(eventName, listener); + res.game = this.game.remove(eventName, listener); + res.stage = this.stage.remove(eventName, listener); + res.step = this.step.remove(eventName, listener); + return res; + }; + + /** + * ### EventEmitterManager.printAll + * + * Prints all registered events + * + * @param {string} eventEmitterName Optional The name of the event emitter + */ + EventEmitterManager.prototype.printAll = function(eventEmitterName) { + var total; + if (eventEmitterName && 'string' !== typeof eventEmitterName) { + throw new TypeError('EventEmitterManager.printAll: ' + + 'eventEmitterName must be string or ' + + 'undefined. Found: ' + eventEmitterName); + } + if (eventEmitterName && !this.ee[eventEmitterName]) { + throw new TypeError('EventEmitterManager.printAll: event' + + 'emitter not found: ' + eventEmitterName); + } + if (eventEmitterName) { + total = this.ee[eventEmitterName].printAll(); + } + else { + total = 0; + total += this.ng.printAll(); + total += this.game.printAll(); + total += this.stage.printAll(); + total += this.step.printAll(); + + console.log('Total number of registered listeners: ' + total); + } + return total; + }; + + /** + * ### EventEmitterManager.getAll + * + * Returns all registered events + * + * @param {string} eventEmitterName Optional The name of the event emitter + */ + EventEmitterManager.prototype.getAll = function(eventEmitterName) { + var events; + if (eventEmitterName && 'string' !== typeof eventEmitterName) { + throw new TypeError('EventEmitterManager.getAll: ' + + 'eventEmitterName must be string or ' + + 'undefined. Found: ' + eventEmitterName); + } + if (eventEmitterName && !this.ee[eventEmitterName]) { + throw new TypeError('EventEmitterManager.getAll: event' + + 'emitter not found: ' + eventEmitterName); + } + if (eventEmitterName) { + events = this.ee[eventEmitterName].events; + } + else { + events = { + ng: this.ng.events, + game: this.game.events, + stage: this.stage.events, + step: this.step.events + }; + } + return events; + }; + + /** + * ### EventEmitterManager.getChanges + * + * Returns the list of changes from all event emitters + * + * Considered event emitters: ng, game, stage, step. + * + * @param {boolean} clear Optional. If TRUE, the list of current changes + * is cleared. Default FALSE + * + * @return {object} Object containing changes for all event emitters, or + * null if no changes have been recorded + * + * @see EventEmitter.getChanges + */ + EventEmitterManager.prototype.getChanges = function(clear) { + var changes, tmp; + changes = {}; + tmp = this.ee.ng.getChanges(clear); + if (tmp) changes.ng = tmp; + tmp = this.ee.game.getChanges(clear); + if (tmp) changes.game = tmp; + tmp = this.ee.stage.getChanges(clear); + if (tmp) changes.stage = tmp; + tmp = this.ee.step.getChanges(clear); + if (tmp) changes.step = tmp; + return J.isEmpty(changes) ? null : changes; + }; + + /** + * ### EventEmitterManager.setRecordChanges + * + * Sets the value of recordChanges for all event emitters and returns it + * + * If called with undefined, just returns current value. + * + * @param {boolean} record If TRUE, starts recording changes. Default FALSE + * + * @return {object} Current values of recordChanges for all event emitters + * + * @see EventEmitter.recordChanges + */ + EventEmitterManager.prototype.setRecordChanges = function(record) { + var out; + out = {}; + out.ng = this.ee.ng.setRecordChanges(record); + out.game = this.ee.game.setRecordChanges(record); + out.stage = this.ee.stage.setRecordChanges(record); + out.step = this.ee.step.setRecordChanges(record); + return out; + }; + + /** + * ### EventEmitterManager.size + * + * Returns the number of registered events / event listeners + * + * Calls the `size` method of each event emitter. + * + * @param {mixed} Optional. Modifier controlling the return value + * + * @return {number} Total number of registered events / event listeners + * + * @see EventEmitter.size + */ + EventEmitterManager.prototype.size = function(mod) { + var count; + count = this.ng.size(mod); + count += this.game.size(mod); + count += this.stage.size(mod); + count += this.step.size(mod); + return count; + }; + + /** + * ## EventHistory constructor + * + * TODO: might require updates. + */ + function EventHistory(node) { + + this.node = node; + + /** + * ### EventHistory.history + * + * Database of emitted events + * + * @see NDDB + * @see EventEmitter.store + * + */ + this.history = new NDDB(); + + this.history.hash('stage', function(e) { + var stage; + if (!e) return; + stage = 'object' === typeof e.stage ? + e.stage : this.node.game.stage; + return node.GameStage.toHash(stage, 'S.s.r'); + }); + + } + + EventHistory.prototype.remit = function(stage, discard, keep) { + var hash, db, remit, node; + node = this.node; + if (!this.history.count()) { + node.warn('no event history was found to remit'); + return false; + } + + node.silly('remitting ' + node.events.history.count() + ' events'); + + if (stage) { + + this.history.rebuildIndexes(); + + hash = new GameStage.toHash(stage, 'S.s.r'); + + if (!this.history.stage) { + node.silly('No past events to re-emit found.'); + return false; + } + if (!this.history.stage[hash]){ + node.silly('Current stage ' + hash + ' has no events ' + + 'to re-emit'); + return false; + } + + db = this.history.stage[hash]; + } + else { + db = this.history; + } + + // cleaning up the events to remit + // TODO NDDB commands have changed, update + if (discard) { + db.select('event', 'in', discard).remove(); + } + + if (keep) { + db = db.select('event', 'in', keep); + } + + if (!db.count()){ + node.silly('no valid events to re-emit after cleanup'); + return false; + } + + remit = function() { + node.silly('re-emitting ' + db.count() + ' events'); + // We have events that were fired at the stage when + // disconnection happened. Let's fire them again + db.each(function(e) { + node.emit(e.event, e.p1, e.p2, e.p3); + }); + }; + + if (node.game.isReady()) { + remit.call(node.game); + } + else { + node.on('LOADED', function(){ + remit.call(node.game); + }); + } + + return true; + }; + + // ## Closure + +})( + 'undefined' !== typeof node ? node : module.exports + , 'undefined' !== typeof node ? node : module.parent.exports +); + +/** + * # GameStage + * + * Copyright(c) 2018 Stefano Balietti + * MIT Licensed + * + * Representation of the stage of a game: + * + * - `stage`: the higher-level building blocks of a game + * - `step`: the sub-unit of a stage + * - `round`: the number of repetition for a stage. Defaults round = 1 + * + * @see GamePlot + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + + // Expose constructor + exports.GameStage = GameStage; + + GameStage.defaults = {}; + + /** + * ### GameStage.defaults.hash + * + * Default hash string for game-stages + * + * @see GameStage.toHash + */ + GameStage.defaults.hash = 'S.s.r'; + + /** + * ## GameStage constructor + * + * Creates an instance of a GameStage + * + * It accepts an object literal, a number, or an hash string as defined in + * `GameStage.defaults.hash`. + * + * The stage and step can be either an integer (1-based index) or a string + * (valid stage/step name). The round must be an integer. + * + * If no parameter is passed, all the properties of the GameStage + * object are set to 0 + * + * @param {object|string|number} gameStage Optional. The game stage + * + * @see GameStage.defaults.hash + */ + function GameStage(gameStage) { + var tokens, stageNum, stepNum, roundNum, err; + + // ## Public properties + + /** + * ### GameStage.stage + * + * The N-th game-block (stage) in the game-plot currently being executed + */ + this.stage = 0; + + /** + * ### GameStage.step + * + * The N-th game-block (step) nested in the current stage + */ + this.step = 0; + + /** + * ### GameStage.round + * + * The number of times the current stage was repeated + */ + this.round = 0; + + // String. + if ('string' === typeof gameStage) { + if (gameStage === '') { + throw new Error('GameStage constructor: gameStage name ' + + 'cannot be an empty string.'); + } + if (gameStage.charAt(0) === '.') { + throw new Error('GameStage constructor: gameStage name ' + + 'cannot start with a dot. Name: ' + gameStage); + } + + tokens = gameStage.split('.'); + + stageNum = parseInt(tokens[0], 10); + this.stage = !isNaN(stageNum) ? stageNum : tokens[0]; + + if ('string' === typeof tokens[1]) { + if (!tokens[1].length) { + throw new Error('GameStage constructor: gameStage ' + + 'contains empty step: ' + gameStage); + } + stepNum = parseInt(tokens[1], 10); + this.step = !isNaN(stepNum) ? stepNum : tokens[1]; + } + else if (this.stage !== 0) { + this.step = 1; + } + if ('string' === typeof tokens[2]) { + if (!tokens[2].length) { + throw new Error('GameStage constructor: gameStage ' + + 'contains empty round: ' + gameStage); + } + roundNum = parseInt(tokens[2], 10); + this.round = roundNum; + } + else if (this.stage !== 0) { + this.round = 1; + } + } + // Not null object. + else if (gameStage && 'object' === typeof gameStage) { + this.stage = gameStage.stage; + this.step = 'undefined' !== typeof gameStage.step ? + gameStage.step : this.stage === 0 ? 0 : 1; + this.round = 'undefined' !== typeof gameStage.round ? + gameStage.round : this.stage === 0 ? 0 : 1; + } + // Number. + else if ('number' === typeof gameStage) { + if (gameStage % 1 !== 0) { + throw new TypeError('GameStage constructor: gameStage ' + + 'cannot be a non-integer number. Found: ' + + gameStage); + } + this.stage = gameStage; + if (this.stage === 0) { + this.step = 0; + this.round = 0; + } + else { + this.step = 1; + this.round = 1; + } + } + // Defaults or error. + else if (gameStage !== null && 'undefined' !== typeof gameStage) { + throw new TypeError('GameStage constructor: gameStage must be ' + + 'string, object, number, undefined, or null. ' + + 'Found: ' + gameStage); + } + + // At this point we must have positive numbers, or strings for step + // and stage, round can be only a positive number, or 0.0.0. + if ('number' === typeof this.stage) { + if (this.stage < 0) err = 'stage'; + } + else if ('string' !== typeof this.stage) { + throw new Error('GameStage constructor: gameStage.stage must be ' + + 'number or string: ' + typeof this.stage); + } + + if ('number' === typeof this.step) { + if (this.step < 0) err = err ? err + ', step' : 'step'; + } + else if ('string' !== typeof this.step) { + throw new Error('GameStage constructor: gameStage.step must be ' + + 'number or string: ' + typeof this.step); + } + + if ('number' === typeof this.round) { + if (this.round < 0) err = err ? err + ', round' : 'round'; + } + else { + throw new Error('GameStage constructor: gameStage.round must ' + + 'be number. Found: ' + this.round); + } + + if (err) { + throw new TypeError('GameStage constructor: ' + err + ' field/s ' + + 'contain/s negative numbers.'); + } + + // Either 0.0.0 or no 0 is allowed. + if (!(this.stage === 0 && this.step === 0 && this.round === 0)) { + if (this.stage === 0 || this.step === 0 || this.round === 0) { + throw new Error('GameStage constructor: malformed game ' + + 'stage: ' + this.toString()); + } + } + } + + // ## GameStage methods + + /** + * ### GameStage.toString + * + * Converts the current instance of GameStage to a string + * + * @return {string} out The string representation of game stage + */ + GameStage.prototype.toString = function() { + return this.stage + '.' + this.step + '.' + this.round; + }; + + // ## GameStage Static Methods + + /** + * ### GameStage.toHash + * + * Returns a simplified hash of the stage of the GameStage + * + * The following characters are valid to determine the hash string + * + * - S: stage + * - s: step + * - r: round + * + * E.g. + * + * ```javascript + * var gs = new GameStage({ + * round: 1, + * stage: 2, + * step: 1 + * }); + * + * gs.toHash('(R) S.s'); // (1) 2.1 + * ``` + * + * @param {GameStage} gs The game stage to hash + * @param {string} str Optional. The hash code. Default: S.s.r + * + * @return {string} hash The hashed game stages + */ + GameStage.toHash = function(gs, str) { + var hash, i, idx, properties, symbols; + if (!gs || 'object' !== typeof gs) { + throw new TypeError('GameStage.toHash: gs must be object. Found: ' + + gs); + } + if (!str || !str.length) { + return gs.stage + '.' + gs.step + '.' + gs.round; + } + + hash = '', + symbols = 'Ssr', + properties = ['stage', 'step', 'round']; + + for (i = 0; i < str.length; i++) { + idx = symbols.indexOf(str.charAt(i)); + hash += (idx < 0) ? str.charAt(i) : gs[properties[idx]]; + } + return hash; + }; + + /** + * ### GameStage.toObject + * + * Returns a clone of the game stage with Object as prototype + * + * @return {object} A new object + */ + GameStage.toObject = function() { + return { + stage: this.stage, + step: this.step, + round: this.round + }; + }; + + /** + * ### GameStage.compare + * + * Converts inputs to GameStage objects and sort them by sequence order + * + * Returns value is: + * + * - 0 if they represent the same game stage + * - -1 if gs1 is ahead of gs2 + * - +1 if gs2 is ahead of gs1 + * + * The accepted hash string format is the following: + * + * - 'S.s.r' (stage.step.round) + * + * When comparison contains a missing value or a string (e.g. a step id), + * the object is placed ahead. + * + * @param {mixed} gs1 The first game stage to compare + * @param {mixed} gs2 The second game stage to compare + * + * @return {number} result The result of the comparison + * + * @see GameStage constructor + * @see GameStage.toHash (static) + */ + GameStage.compare = function(gs1, gs2) { + var result; + // null, undefined, 0. + if (!gs1 && !gs2) return 0; + if (!gs2) return -1; + if (!gs1) return 1; + + gs1 = new GameStage(gs1); + gs2 = new GameStage(gs2); + + if ('number' === typeof gs1.stage) { + if ('number' === typeof gs2.stage) { + result = gs2.stage - gs1.stage; + } + else { + result = -1; + } + } + else if ('number' === typeof gs2.stage) { + result = 1; + } + + if (result === 0) { + if ('number' === typeof gs1.round) { + if ('number' === typeof gs2.round) { + result = gs2.round - gs1.round; + } + else { + result = -1; + } + + } + else if ('number' === typeof gs2.round) { + result = 1; + } + } + + if (result === 0) { + if ('number' === typeof gs1.step) { + if ('number' === typeof gs2.step) { + result = gs2.step - gs1.step; + } + else { + result = -1; + } + + } + else if ('number' === typeof gs2.step) { + result = 1; + } + } + + return result > 0 ? 1 : result < 0 ? -1 : 0; + }; + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # PlayerList + * Copyright(c) 2016 Stefano Balietti + * MIT Licensed + * + * Handles a collection of `Player` objects + * + * Offers methods to update, search and retrieve players. + * + * It extends the NDDB class. + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + + // Exposing constructor + exports.PlayerList = PlayerList; + + // Setting up global scope variables + var J = parent.JSUS, + NDDB = parent.NDDB, + GameStage = parent.GameStage; + + var stageLevels = parent.constants.stageLevels; + var stateLevels = parent.constants.stateLevels; + + // Inheriting from NDDB + PlayerList.prototype = new NDDB(); + PlayerList.prototype.constructor = PlayerList; + + // Sync types used by PlayerList.arePlayersSync + var syncTypes; + + /** + * ## PlayerList.comparePlayers + * + * Comparator functions between two players + * + * @param {Player} p1 The first player + * @param {Player} p2 The second player + * @return {number} The result of the comparison + * + * @see NDDB.globalCompare + */ + PlayerList.comparePlayers = function(p1, p2) { + if (p1.id === p2.id) return 0; + if (p1.count < p2.count) return 1; + if (p1.count > p2.count) return -1; + return 0; + }; + + /** + * ## PlayerList constructor + * + * Creates an instance of PlayerList + * + * The class inherits his prototype from `node.NDDB`. + * + * It indexes players by their _id_. + * + * @param {object} options Optional. Configuration object + * @param {array} db Optional. An initial set of players to import + * @param {PlayerList} parent Optional. A parent object for the instance + * + * @see NDDB.constructor + */ + function PlayerList(options, db) { + options = options || {}; + + options.name = options.name || 'plist'; + + // Updates indexes on the fly. + if (!options.update) options.update = {}; + if ('undefined' === typeof options.update.indexes) { + options.update.indexes = true; + } + + // The internal counter that will be used to assing the `count` + // property to each inserted player. + this.pcounter = 0; + + // Invoking NDDB constructor. + NDDB.call(this, options); + + // We check if the index are not existing already because + // it could be that the constructor is called by the breed function + // and in such case we would duplicate them. + if (!this.id) { + this.index('id', function(p) { + return p.id; + }); + } + + // Importing initial items + // (should not be done in constructor of NDDB) + if (db) this.importDB(db); + + // Assigns a global comparator function. + this.globalCompare = PlayerList.comparePlayers; + } + + // ## PlayerList methods + + /** + * ### PlayerList.importDB + * + * Adds an array of players to the database at once + * + * Overrides NDDB.importDB + * + * @param {array} db The array of player to import at once + */ + PlayerList.prototype.importDB = function(db) { + var i, len; + if (!J.isArray(db)) { + throw new TypeError('PlayerList.importDB: db must be array.'); + } + i = -1, len = db.length; + for ( ; ++i < len ; ) { + this.add(db[i]); + } + }; + + /** + * ### PlayerList.add + * + * Adds a new player to the database + * + * Before insertion, objects are checked to be valid `Player` objects, + * that is they must have a unique player id. Objects will then + * automatically casted to type Player. + * + * The `count` property is added to the player object, and + * the internal `pcounter` variable is incremented. + * + * @param {Player} player The player object to add to the database + * @param {object} updateRules Optional. Update rules overwriting + * `this.__update` + * + * @return {player} The inserted player + */ + PlayerList.prototype.add = function(player, updateRules) { + if (!(player instanceof Player)) { + if ('object' !== typeof player) { + throw new TypeError('PlayerList.add: player must be object. ' + + 'Found: ' + player); + } + if ('string' !== typeof player.id) { + throw new TypeError('PlayerList.add: player.id must be ' + + 'string. Found: ' + player.id); + } + player = new Player(player); + } + + if (this.exist(player.id)) { + throw new Error('PlayerList.add: player already existing: ' + + player.id + '.'); + } + this.insert(player, updateRules); + player.count = this.pcounter; + this.pcounter++; + return player; + }; + +// NEW GET AND REMOVE (no errors are thrown) + +// /** +// * ### PlayerList.get +// * +// * Retrieves a player with the given id +// * +// * @param {number} id The client id of the player to retrieve +// * +// * @return {Player} The player with the speficied id +// */ +// PlayerList.prototype.get = function(id) { +// if ('string' !== typeof id) { +// throw new TypeError('PlayerList.get: id must be string.'); +// } +// return this.id.get(id); +// }; +// +// /** +// * ### PlayerList.remove +// * +// * Removes the player with the given id +// * +// * Notice: this operation cannot be undone +// * +// * @param {number} id The id of the player to remove +// * +// * @return {object} The removed player object +// */ +// PlayerList.prototype.remove = function(id) { +// if ('string' !== typeof id) { +// throw new TypeError('PlayerList.remove: id must be string.'); +// } +// return this.id.remove(id); +// }; + +// OLD GET AND REMOVE: throw errors + + /** + * ### PlayerList.get + * + * Retrieves a player with the given id + * + * @param {number} id The id of the player to retrieve + * + * @return {Player} The player with the speficied id + */ + PlayerList.prototype.get = function(id) { + var player; + if ('string' !== typeof id) { + throw new TypeError('PlayerList.get: id must be string'); + + } + player = this.id.get(id); + if (!player) { + throw new Error('PlayerList.get: Player not found: ' + id); + } + return player; + }; + + /** + * ### PlayerList.remove + * + * Removes the player with the given id + * + * Notice: this operation cannot be undone + * + * @param {number} id The id of the player to remove + * + * @return {object} The removed player object + */ + PlayerList.prototype.remove = function(id) { + var player; + if ('string' !== typeof id) { + throw new TypeError('PlayerList.remove: id must be string. ' + + 'Found: ' + id); + } + player = this.id.remove(id); + if (!player) { + throw new Error('PlayerList.remove: player not found: ' + id); + } + return player; + }; + + // ### PlayerList.pop + // @deprecated + // TODO remove after transition is complete + PlayerList.prototype.pop = PlayerList.prototype.remove; + + /** + * ### PlayerList.exist + * + * Checks whether a player with the given id already exists + * + * @param {string} id The id of the player + * + * @return {boolean} TRUE, if a player with the specified id is found + */ + PlayerList.prototype.exist = function(id) { + return this.id.get(id) ? true : false; + }; + + /** + * ### PlayerList.clear + * + * Clears the PlayerList and rebuilds the indexes + */ + PlayerList.prototype.clear = function() { + NDDB.prototype.clear.call(this); + // We need this to recreate the (empty) indexes. + this.rebuildIndexes(); + }; + + /** + * ### PlayerList.updatePlayer + * + * Updates the state of a player + * + * @param {number} id The id of the player + * @param {object} playerState An update with fields to update in the player + * + * @return {object} The updated player object + */ + PlayerList.prototype.updatePlayer = function(id, update) { + var player; + if ('string' !== typeof id) { + throw new TypeError( + 'PlayerList.updatePlayer: id must be string. Found: ' + id); + } + if ('object' !== typeof update) { + throw new TypeError( + 'PlayerList.updatePlayer: update must be object. Found: ' + + update); + } + + if ('undefined' !== typeof update.id) { + throw new Error('PlayerList.updatePlayer: update cannot change ' + + 'the player id.'); + } + + player = this.id.update(id, update); + + if (!player) { + throw new Error( + 'PlayerList.updatePlayer: player not found: ' + id); + } + + return player; + }; + + /** + * ### PlayerList.isStepDone + * + * Checks whether all players have terminated the specified game step + * + * A stage is considered _DONE_ if all players that are found playing + * that game step have the property `stageLevel` equal to: + * + * `node.constants.stageLevels.DONE`. + * + * By default, players at other steps are ignored. + * + * If no player is found at the desired step, it returns TRUE + * + * @param {GameStage} gameStage The GameStage of reference + * @param {string} type Optional. The type of checking. Default 'EXACT' + * @param {boolean} checkOutliers Optional. If TRUE, players at other + * steps are also checked. Default FALSE + * + * @return {boolean} TRUE, if all checked players have terminated the stage + * + * @see PlayerList.arePlayersSync + */ + PlayerList.prototype.isStepDone = function(gameStage, type, checkOutliers) { + return this.arePlayersSync(gameStage, stageLevels.DONE, type, + checkOutliers); + }; + + /** + * ### PlayerList.isStepLoaded + * + * Checks whether all players have loaded the specified game step + * + * A stage is considered _LOADED_ if all players that are found playing + * that game step have the property `stageLevel` equal to: + * + * `node.constants.stageLevels.LOADED`. + * + * By default, players at other steps are ignored. + * + * If no player is found at the desired step, it returns TRUE. + * + * @param {GameStage} gameStage The GameStage of reference + * + * @return {boolean} TRUE, if all checked players have loaded the stage + * + * @see PlayerList.arePlayersSync + */ + PlayerList.prototype.isStepLoaded = function(gameStage) { + return this.arePlayersSync(gameStage, stageLevels.LOADED, 'EXACT'); + }; + + /** + * ### PlayerList.arePlayersSync + * + * Verifies that all players in the same stage are at the same stageLevel + * + * Players at other game steps are ignored, unless the + * `checkOutliers` parameter is set. In this case, if players are + * found in earlier game steps, the method will return + * false. Players at later game steps will still be ignored. + * + * The `type` parameter can assume one of the following values: + * + * - 'EXACT': same stage, step, round + * - 'STAGE': same stage, but different steps and rounds are accepted + * - 'STAGE_UPTO': up to the same stage is ok + * + * Finally, if `stageLevel` is set, it even checks for the stageLevel, + * for example: PLAYING, DONE, etc. + * + * TODO: see the checkOutliers param, if it is needed after all. + * + * @param {GameStage} gameStage The GameStage of reference + * @param {number} stageLevel The stageLevel of reference + * @param {string} type Optional. Flag to say what players will be checked + * @param {boolean} checkOutliers Optional. Whether to check for outliers. + * Can't be TRUE if type is 'exact' + * + * @return {boolean} TRUE, if all checked players are sync + */ + PlayerList.prototype.arePlayersSync = function(gameStage, stageLevel, type, + checkOutliers) { + + var p, i, len, cmp, outlier; + + // Cast the gameStage to object. It can throw errors. + gameStage = new GameStage(gameStage); + + if ('undefined' !== typeof stageLevel && + 'number' !== typeof stageLevel) { + + throw new TypeError('PlayerList.arePlayersSync: stagelevel must ' + + 'be number or undefined.'); + } + + type = type || 'EXACT'; + if ('string' !== typeof type) { + throw new TypeError('PlayerList.arePlayersSync: type must be ' + + 'string or undefined.'); + } + + if ('undefined' === typeof syncTypes[type]) { + throw new Error('PlayerList.arePlayersSync: unknown type: ' + + type + '.'); + } + + checkOutliers = 'undefined' === typeof checkOutliers ? + true : !!checkOutliers; + + if (!checkOutliers && type === 'EXACT') { + throw new Error('PlayerList.arePlayersSync: incompatible options:' + + ' type=EXACT and checkOutliers=FALSE.'); + } + + i = -1, len = this.db.length; + for ( ; ++i < len ; ) { + + p = this.db[i]; + + switch(type) { + + case 'EXACT': + // Players in same stage, step and round. + cmp = GameStage.compare(gameStage, p.stage); + if (cmp !== 0) return false; + break; + + case 'STAGE': + if (gameStage.stage !== p.stage.stage) { + outlier = true; + } + break; + + case 'STAGE_UPTO': + // Players in current stage up to the reference step. + cmp = GameStage.compare(gameStage, p.stage); + // Player in another stage or in later step. + if (gameStage.stage !== p.stage.stage || cmp < 0) { + outlier = true; + break; + } + // Player before given step. + if (cmp > 0) return false; + + break; + } + + // If outliers are not allowed returns false if one was found. + if (checkOutliers && outlier) return false; + + // If the stageLevel check is required let's do it! + if ('undefined' !== typeof stageLevel && + p.stageLevel !== stageLevel) { + + return false; + } + } + return true; + }; + + /** + * ### PlayerList.toString + * + * Returns a string representation of the PlayerList + * + * @param {string} eol Optional. End of line separator between players + * + * @return {string} out The string representation of the PlayerList + */ + PlayerList.prototype.toString = function(eol) { + var out, EOL; + out = '', EOL = eol || '\n'; + this.each(function(p) { + var stage; + out += p.id + ': ' + p.name; + stage = new GameStage(p.stage); + out += ': ' + stage + EOL; + }); + return out; + }; + + /** + * ### PlayerList.getNGroups + * + * Creates N random groups of players + * + * @param {number} N The number of groups + * + * @return {array} Array containing N `PlayerList` objects + * + * @see JSUS.getNGroups + */ + PlayerList.prototype.getNGroups = function(N) { + var groups; + if ('number' !== typeof N || isNaN(N) || N < 1) { + throw new TypeError('PlayerList.getNGroups: N must be a number ' + + '> 0: ' + N); + } + groups = J.getNGroups(this.db, N); + return array2Groups.call(this, groups); + }; + + /** + * ### PlayerList.getGroupsSizeN + * + * Creates random groups of N players + * + * @param {number} N The number player per group + * + * @return {array} Array containing N `PlayerList` objects + * + * @see JSUS.getGroupsSizeN + */ + PlayerList.prototype.getGroupsSizeN = function(N) { + var groups; + if ('number' !== typeof N || isNaN(N) || N < 1) { + throw new TypeError('PlayerList.getNGroups: N must be a number ' + + '> 0: ' + N); + } + groups = J.getGroupsSizeN(this.db, N); + return array2Groups.call(this, groups); + }; + + /** + * ### PlayerList.getRandom + * + * Returns a set of N random players + * + * @param {number} N The number of players in the random set. Defaults N = 1 + * + * @return {Player|array} A single player object or an array of + */ + PlayerList.prototype.getRandom = function(N) { + var shuffled; + if ('undefined' === typeof N) N = 1; + if ('number' !== typeof N || isNaN(N) || N < 1) { + throw new TypeError('PlayerList.getRandom: N must be a number ' + + '> 0 or undefined: ' + N + '.'); + } + shuffled = this.shuffle(); + return N === 1 ? shuffled.first() : shuffled.limit(N).fetch(); + }; + + + // ## Helper Methods and Objects + + /** + * ### array2Groups + * + * Transforms an array of array (of players) into an + * array of PlayerList instances and returns it. + * + * The original array is modified. + * + * @param {array} array The array to transform + * + * @return {array} array The array of `PlayerList` objects + */ + function array2Groups(array) { + var i, len, settings; + settings = this.cloneSettings(); + i = -1, len = array.length; + for ( ; ++i < len ; ) { + array[i] = new PlayerList(settings, array[i]); + } + return array; + } + + syncTypes = {STAGE: '', STAGE_UPTO: '', EXACT: ''}; + + /** + * # Player + * + * Wrapper for a number of properties for players + * + * `sid`: The Socket.io session id associated to the player + * `id`: The nodeGame session id associate to the player + * `count`: The id of the player within a PlayerList object + * `admin`: Whether the player is an admin + * `disconnected`: Whether the player has disconnected + * `lang`: the language chosen by player (default English) + * `name`: An alphanumeric name associated to the player + * `stage`: The current stage of the player as relative to a game + * `ip`: The ip address of the player + * + */ + + // Expose Player constructor + exports.Player = Player; + + /** + * ## Player constructor + * + * Creates an instance of Player + * + * @param {object} player The object literal representing the player. + * Must contain at very least the `id` property + */ + function Player(player) { + var key; + + if ('object' !== typeof player) { + throw new TypeError('Player constructor: player must be object. ' + + 'Found: ' + player); + } + if ('string' !== typeof player.id) { + throw new TypeError('Player constructor: id must be string. ' + + 'Found: ' + player.id); + } + + // ## Default properties + + /** + * ### Player.id + * + * The nodeGame session id associate to the player + * + * Usually it is the same as the Socket.io id, but in + * case of reconnections it can change + */ + this.id = player.id; + + /** + * ### Player.sid + * + * The session id received from the nodeGame server + */ + this.sid = player.sid; + + /** + * ### Player.clientType + * + * The client type (e.g. player, admin, bot, ...) + */ + this.clientType = player.clientType || null; + + /** + * ### Player.group + * + * The group to which the player belongs + */ + this.group = player.group || null; + + /** + * ### Player.role + * + * The role of the player + */ + this.role = player.role || null; + + /** + * ### Player.partner + * + * The partner of the player + */ + this.partner = player.partner || null; + + /** + * ### Player.count + * + * The ordinal position of the player in a PlayerList object + * + * @see PlayerList + */ + this.count = 'undefined' === typeof player.count ? null : player.count; + + /** + * ### Player.admin + * + * The admin status of the client + */ + this.admin = !!player.admin; + + /** + * ### Player.disconnected + * + * The connection status of the client + */ + this.disconnected = !!player.disconnected; + + /** + * ### Player.ip + * + * The ip address of the player + * + * Note: this can change in mobile networks + */ + this.ip = player.ip || null; + + /** + * ### Player.name + * + * An alphanumeric name associated with the player + */ + this.name = player.name || null; + + /** + * ### Player.stage + * + * Reference to the game-stage the player currently is + * + * @see node.game.stage + * @see GameStage + */ + this.stage = player.stage || new GameStage(); + + /** + * ### Player.stageLevel + * + * The current stage level of the player in the game + * + * @see node.stageLevels + */ + this.stageLevel = player.stageLevel || stageLevels.UNINITIALIZED; + + /** + * ### Player.stateLevel + * + * The current state level of the player in the game + * + * @see node.stateLevels + */ + this.stateLevel = player.stateLevel || stateLevels.UNINITIALIZED; + + /** + * ### Player.lang + * + * The current language used by the player + * + * Default language is English with the default path `en/`. + */ + this.lang = { + name: 'English', + shortName: 'en', + nativeName: 'English', + path: 'en/' + }; + + /** + * ## Extra properties + * + * For security reasons, they cannot be of type function, and they + * cannot overwrite any previously defined variable + */ + for (key in player) { + if (player.hasOwnProperty(key)) { + if ('function' !== typeof player[key]) { + if (!this.hasOwnProperty(key)) { + this[key] = player[key]; + } + } + } + } + } + + // ## Player methods + + /** + * ### Player.toString + * + * Returns a string representation of a player + * + * @return {string} The string representation of a player + */ + Player.prototype.toString = function() { + return (this.name || '' ) + ' (' + this.id + ') ' + + new GameStage(this.stage); + }; + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports + , 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # GameMsg + * + * Copyright(c) 2018 Stefano Balietti + * MIT Licensed + * + * `nodeGame` exchangeable data format + */ +(function(exports, node) { + + "use strict"; + + // ## Global scope + var GameStage = node.GameStage, + J = node.JSUS; + + exports.GameMsg = GameMsg; + + /** + * ### GameMSg.clone (static) + * + * Returns a perfect copy of a game-message + * + * @param {GameMsg} gameMsg The message to clone + * @return {GameMsg} The cloned messaged + */ + GameMsg.clone = function(gameMsg) { + return new GameMsg(gameMsg); + }; + + /** + * ## GameMsg constructor + * + * Creates an instance of GameMsg + * + * @param {object} gm Optional. Initial values for the game message fields + */ + function GameMsg(gm) { + gm = gm || {}; + + /** + * ### GameMsg.id + * + * A randomly generated unique id + */ + this.id = 'undefined' === typeof gm.id ? + Math.floor(Math.random()*1000000) : gm.id; + + /** + * ### GameMsg.sid + * + * The socket id, if provided + * + * Used by SocketIO to prevent spoofing, not used by other sockets + * TODO: could this be session instead? + */ + this.sid = gm.sid; + + /** + * ### GameMsg.session + * + * The session id in which the message was generated + */ + this.session = gm.session; + + /** + * ### GameMsg.stage + * + * The game-stage in which the message was generated + * + * @see GameStage + */ + this.stage = gm.stage; + + /** + * ### GameMsg.action + * + * The action of the message + * + * @see node.constants.action + */ + this.action = gm.action; + + /** + * ### GameMsg.target + * + * The target of the message + * + * @see node.constants.target + */ + this.target = gm.target; + + /** + * ### GameMsg.from + * + * The id of the sender of the message + * + * @see Player.id + * @see node.player.id + */ + this.from = gm.from; + + /** + * ### GameMsg.to + * + * The id of the receiver of the message + * + * @see Player.id + * @see node.player.id + */ + this.to = gm.to; + + /** + * ### GameMsg.text + * + * An optional text adding a description for the message + */ + this.text = gm.text; + + /** + * ### GameMsg.data + * + * An optional payload field for the message + */ + this.data = gm.data; + + /** + * ### GameMsg.priority + * + * A priority index associated to the message + */ + this.priority = gm.priority; + + /** + * ### GameMsg.reliable + * + * Experimental. Disabled for the moment + * + * If set, requires ackwnoledgment of delivery + */ + this.reliable = gm.reliable; + + /** + * ### GameMsg.created + * + * A timestamp of the date of creation + */ + this.created = J.getDate(); + + /** + * ### GameMsg.forward + * + * If TRUE, the message is a forward. + * + * E.g. between nodeGame servers + */ + this.forward = 0; + } + + /** + * ### GameMsg.stringify + * + * Calls JSON.stringify on the message + * + * @return {string} The stringified game-message + * + * @see GameMsg.toString + */ + GameMsg.prototype.stringify = function() { + return JSON.stringify(this); + }; + + // ## GameMsg methods + + /** + * ### GameMsg.toString + * + * Creates a human readable string representation of the message + * + * @return {string} The string representation of the message + * @see GameMsg.stringify + */ + GameMsg.prototype.toString = function() { + var SPT, TAB, DLM, line, UNKNOWN, tmp; + SPT = ",\t"; + TAB = "\t"; + DLM = "\""; + UNKNOWN = "\"unknown\"\t"; + line = this.created + SPT; + line += this.id + SPT; + line += this.session + SPT; + line += this.action + SPT; + + line += this.target ? + this.target.length < 6 ? + this.target + SPT + TAB : this.target + SPT : UNKNOWN; + line += this.from ? + this.from.length < 6 ? + this.from + SPT + TAB : this.from + SPT : UNKNOWN; + line += this.to ? + this.to.length < 6 ? + this.to + SPT + TAB : this.to + SPT : UNKNOWN; + + if (this.text === null || 'undefined' === typeof this.text) { + line += "\"no text\"" + SPT; + } + else if ('number' === typeof this.text) { + line += "" + this.text; + } + else { + tmp = this.text.toString(); + + if (tmp.length > 12) { + line += DLM + tmp.substr(0,9) + "..." + DLM + SPT; + } + else if (tmp.length < 6) { + line += DLM + tmp + DLM + SPT + TAB; + } + else { + line += DLM + tmp + DLM + SPT; + } + } + + if (this.data === null || 'undefined' === typeof this.data) { + line += "\"no data\"" + SPT; + } + else if ('number' === typeof this.data) { + line += "" + this.data; + } + else { + tmp = this.data.toString(); + if (tmp.length > 12) { + line += DLM + tmp.substr(0,9) + "..." + DLM + SPT; + } + else if (tmp.length < 9) { + line += DLM + tmp + DLM + SPT + TAB; + } + else { + line += DLM + tmp + DLM + SPT; + } + } + + line += new GameStage(this.stage) + SPT; + line += this.reliable + SPT; + line += this.priority; + return line; + }; + + /** + * ### GameMSg.toSMS + * + * Creates a compact visualization of the most important properties + * + * @return {string} A compact string representing the message + * + * TODO: Create an hash method as for GameStage + */ + GameMsg.prototype.toSMS = function() { + var line = '[' + this.from + ']->[' + this.to + ']\t'; + line += '|' + this.action + '.' + this.target + '|'+ '\t'; + line += ' ' + this.text + ' '; + return line; + }; + + /** + * ### GameMsg.toInEvent + * + * Hashes the action and target properties of an incoming message + * + * @return {string} The hash string + * @see GameMsg.toEvent + */ + GameMsg.prototype.toInEvent = function() { + return 'in.' + this.toEvent(); + }; + + /** + * ### GameMsg.toOutEvent + * + * Hashes the action and target properties of an outgoing message + * + * @return {string} The hash string + * @see GameMsg.toEvent + */ + GameMsg.prototype.toOutEvent = function() { + return 'out.' + this.toEvent(); + }; + + /** + * ### GameMsg.toEvent + * + * Hashes the action and target properties of the message + * + * @return {string} The hash string + */ + GameMsg.prototype.toEvent = function() { + return this.action + '.' + this.target; + }; + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # GamePlot + * Copyright(c) 2020 Stefano Balietti + * MIT Licensed + * + * Wraps a stager and exposes methods to navigate through the sequence + * + * TODO: previousStage + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + exports.GamePlot = GamePlot; + + var GameStage = parent.GameStage; + var J = parent.JSUS; + + // ## Constants + GamePlot.GAMEOVER = 'NODEGAME_GAMEOVER'; + GamePlot.END_SEQ = 'NODEGAME_END_SEQ'; + GamePlot.NO_SEQ = 'NODEGAME_NO_SEQ'; + + /** + * ## GamePlot constructor + * + * Creates a new instance of GamePlot + * + * Takes a sequence object created with Stager. + * + * If the Stager parameter has an empty sequence, flexible mode is assumed + * (used by e.g. GamePlot.next). + * + * @param {NodeGameClient} node Reference to current node object + * @param {Stager} stager Optional. The Stager object. + * + * @see Stager + */ + function GamePlot(node, stager) { + + // ## GamePlot Properties + + /** + * ### GamePlot.node + * + * Reference to the node object + */ + this.node = node; + + /** + * ### GamePlot.stager + * + * The stager object used to perform stepping operations + */ + this.stager = null; + + /** + * ### GamePlot.cache + * + * Caches the value of previously fetched properties per game stage + */ + this.cache = {}; + + /** + * ### GamePlot.tmpCache + * + * Handles a temporary cache for properties of current step + * + * If set, properties are served first by the `getProperty` method. + * This cache is deleted each time a step is done. + * Used, for example, to reset some properties upon reconnect. + * + * Defined two additional methods: + * + * - tmpCache.hasOwnProperty + * - tmpCache.clear + * + * @param {string} prop the name of the property to retrieve or set + * @param {mixed} value The value of property to set + * + * @return {mixed} The current value of the property + */ + this.tmpCache = (function() { + var tmpCache, handler; + tmpCache = {}; + handler = function(prop, value) { + if ('undefined' === typeof prop) { + return tmpCache; + } + else if ('string' === typeof prop) { + if (arguments.length === 1) return tmpCache[prop]; + tmpCache[prop] = value; + return value; + } + + throw new TypeError('GamePlot.tmpCache: prop must be ' + + 'string. Found: ' + prop); + }; + + handler.clear = function() { + var tmp; + tmp = tmpCache; + tmpCache = {}; + return tmp; + }; + + handler.hasOwnProperty = function(prop) { + if ('string' !== typeof prop) { + throw new TypeError('GamePlot.tmpCache.hasProperty: ' + + 'prop must be string. Found: ' + + prop); + } + return tmpCache.hasOwnProperty(prop); + }; + + return handler; + })(); + + /** + * ### GamePlot._normalizedCache + * + * Caches the value of previously normalized Game Stages objects. + * + * @api private + */ + this._normalizedCache = {}; + + this.init(stager); + } + + // ## GamePlot methods + + /** + * ### GamePlot.init + * + * Initializes the GamePlot with a stager + * + * Clears the cache also. + * + * @param {Stager} stager Optional. The Stager object. + * + * @see Stager + */ + GamePlot.prototype.init = function(stager) { + if (stager) { + if ('object' !== typeof stager) { + throw new Error('GamePlot.init: called with invalid stager.'); + } + this.stager = stager; + } + else { + this.stager = null; + } + this.cache = {}; + this.tmpCache.clear(); + }; + + /** + * ### GamePlot.next + * + * Returns the next step in the sequence + * + * If the step in `curStage` is an integer and out of bounds, + * that bound is assumed. + * + * // TODO: previousStage + * + * @param {GameStage} curStage The GameStage of reference + * @param {bolean} execLoops Optional. If true, loop and doLoop + * conditional function will be executed to determine next stage. + * If false, null will be returned if the next stage depends + * on the execution of the loop/doLoop conditional function. + * Default: true. + * + * @return {GameStage|string} The GameStage after _curStage_ + * + * @see GameStage + */ + GamePlot.prototype.nextStage = function(curStage, execLoops) { + var seqObj, stageObj; + var stageNo, stepNo, steps; + var normStage, nextStage; + var flexibleMode; + + // GamePlot was not correctly initialized. + if (!this.stager) return GamePlot.NO_SEQ; + + flexibleMode = this.isFlexibleMode(); + if (flexibleMode) { + // TODO. What does next stage mean in flexible mode? + // Calling the next cb of the last step? A separate cb? + console.log('***GamePlot.nextStage: method not available in ' + + 'flexible mode.***'); + return null; + } + + // Standard Mode. + else { + // Get normalized GameStage: + // makes sures stage is with numbers and not strings. + normStage = this.normalizeGameStage(curStage); + if (normStage === null) { + this.node.silly('GamePlot.nextStage: invalid stage: ' + + curStage); + return null; + } + + stageNo = normStage.stage; + + if (stageNo === 0) { + return new GameStage({ + stage: 1, + step: 1, + round: 1 + }); + } + seqObj = this.stager.sequence[stageNo - 1]; + + if (seqObj.type === 'gameover') return GamePlot.GAMEOVER; + + execLoops = 'undefined' === typeof execLoops ? true : execLoops; + + // Get stage object. + stageObj = this.stager.stages[seqObj.id]; + + // Go to next stage. + if (stageNo < this.stager.sequence.length) { + seqObj = this.stager.sequence[stageNo]; + + // Return null if a loop is found and can't be executed. + if (!execLoops && seqObj.type === 'loop') return null; + + // Skip over loops if their callbacks return false: + while (seqObj.type === 'loop' && + !seqObj.cb.call(this.node.game)) { + + stageNo++; + if (stageNo >= this.stager.sequence.length) { + return GamePlot.END_SEQ; + } + // Update seq object. + seqObj = this.stager.sequence[stageNo]; + } + + // Handle gameover: + if (this.stager.sequence[stageNo].type === 'gameover') { + return GamePlot.GAMEOVER; + } + + return new GameStage({ + stage: stageNo + 1, + step: 1, + round: 1 + }); + } + + // No more stages remaining: + return GamePlot.END_SEQ; + } + }; + + /** + * ### GamePlot.next + * + * Returns the next step in the sequence + * + * If the step in `curStage` is an integer and out of bounds, + * that bound is assumed. + * + * @param {GameStage} curStage The GameStage of reference + * @param {bolean} execLoops Optional. If true, loop and doLoop + * conditional function will be executed to determine next stage. + * If false, null will be returned if the next stage depends + * on the execution of the loop/doLoop conditional function. + * Default: true. + * + * @return {GameStage|string} The GameStage after _curStage_ + * + * @see GameStage + */ + GamePlot.prototype.next = function(curStage, execLoops) { + var seqObj, stageObj; + var stageNo, stepNo, steps; + var normStage, nextStage; + var flexibleMode; + + // GamePlot was not correctly initialized. + if (!this.stager) return GamePlot.NO_SEQ; + + // Init variables. + seqObj = null, stageObj = null, normStage = null, nextStage = null; + // Find out flexibility mode. + flexibleMode = this.isFlexibleMode(); + + if (flexibleMode) { + curStage = new GameStage(curStage); + + if (curStage.stage === 0) { + // Get first stage: + if (this.stager.generalNextFunction) { + nextStage = this.stager.generalNextFunction(); + } + + if (nextStage) { + return new GameStage({ + stage: nextStage, + step: 1, + round: 1 + }); + } + + return GamePlot.END_SEQ; + } + + // Get stage object: + stageObj = this.stager.stages[curStage.stage]; + + if ('undefined' === typeof stageObj) { + throw new Error('Gameplot.next: received non-existent stage: ' + + curStage.stage); + } + + // Find step number: + if ('number' === typeof curStage.step) { + stepNo = curStage.step; + } + else { + stepNo = stageObj.steps.indexOf(curStage.step) + 1; + } + if (stepNo < 1) { + throw new Error('GamePlot.next: received non-existent step: ' + + stageObj.id + '.' + curStage.step); + } + + // Handle stepping: + if (stepNo + 1 <= stageObj.steps.length) { + return new GameStage({ + stage: stageObj.id, + step: stepNo + 1, + round: 1 + }); + } + + // Get next stage: + if (this.stager.nextFunctions[stageObj.id]) { + nextStage = this.stager.nextFunctions[stageObj.id](); + } + else if (this.stager.generalNextFunction) { + nextStage = this.stager.generalNextFunction(); + } + + // If next-deciding function returns GamePlot.GAMEOVER, + // consider it game over. + if (nextStage === GamePlot.GAMEOVER) { + return GamePlot.GAMEOVER; + } + else if (nextStage) { + return new GameStage({ + stage: nextStage, + step: 1, + round: 1 + }); + } + + return GamePlot.END_SEQ; + } + + // Standard Mode. + else { + // Get normalized GameStage: + // makes sures stage is with numbers and not strings. + normStage = this.normalizeGameStage(curStage); + if (normStage === null) { + this.node.silly('GamePlot.next: invalid stage: ' + curStage); + return null; + } + + stageNo = normStage.stage; + + if (stageNo === 0) { + return new GameStage({ + stage: 1, + step: 1, + round: 1 + }); + } + + stepNo = normStage.step; + seqObj = this.stager.sequence[stageNo - 1]; + + if (seqObj.type === 'gameover') return GamePlot.GAMEOVER; + + execLoops = 'undefined' === typeof execLoops ? true : execLoops; + + // Get stage object. + stageObj = this.stager.stages[seqObj.id]; + + steps = seqObj.steps; + + // Handle stepping: + if (stepNo + 1 <= steps.length) { + return new GameStage({ + stage: stageNo, + step: stepNo + 1, + round: normStage.round + }); + } + + // Handle repeat block: + if (seqObj.type === 'repeat' && normStage.round + 1 <= seqObj.num) { + return new GameStage({ + stage: stageNo, + step: 1, + round: normStage.round + 1 + }); + } + + // Handle looping blocks: + if (seqObj.type === 'doLoop' || seqObj.type === 'loop') { + + // Return null if a loop is found and can't be executed. + if (!execLoops) return null; + + // Call loop function. True means continue loop. + if (seqObj.cb.call(this.node.game)) { + return new GameStage({ + stage: stageNo, + step: 1, + round: normStage.round + 1 + }); + } + } + + // Go to next stage. + if (stageNo < this.stager.sequence.length) { + seqObj = this.stager.sequence[stageNo]; + + // Return null if a loop is found and can't be executed. + if (!execLoops && seqObj.type === 'loop') return null; + + // Skip over loops if their callbacks return false: + while (seqObj.type === 'loop' && + !seqObj.cb.call(this.node.game)) { + + stageNo++; + if (stageNo >= this.stager.sequence.length) { + return GamePlot.END_SEQ; + } + // Update seq object. + seqObj = this.stager.sequence[stageNo]; + } + + // Handle gameover: + if (this.stager.sequence[stageNo].type === 'gameover') { + return GamePlot.GAMEOVER; + } + + return new GameStage({ + stage: stageNo + 1, + step: 1, + round: 1 + }); + } + + // No more stages remaining: + return GamePlot.END_SEQ; + } + }; + + /** + * ### GamePlot.previous + * + * Returns the previous step in the sequence + * + * Works only in simple mode. + * + * Previous of 0.0.0 is 0.0.0. + * + * @param {GameStage} curStage The GameStage of reference + * @param {bolean} execLoops Optional. If true, loop and doLoop + * conditional function will be executed to determine previous stage. + * If false, null will be returned if the previous stage depends + * on the execution of the loop/doLoop conditional function. + * Default: true. + * + * @return {GameStage|null} The GameStage before _curStage_, or null + * if _curStage_ is invalid. + * + * @see GameStage + */ + GamePlot.prototype.previous = function(curStage, execLoops) { + var normStage; + var seqObj, stageObj; + var prevSeqObj; + var stageNo, stepNo, prevStepNo; + + // GamePlot was not correctly initialized. + if (!this.stager) return GamePlot.NO_SEQ; + + seqObj = null, stageObj = null; + + // Get normalized GameStage (calls GameStage constructor). + normStage = this.normalizeGameStage(curStage); + if (normStage === null) { + this.node.warn('GamePlot.previous: invalid stage: ' + curStage); + return null; + } + stageNo = normStage.stage; + + // Already 0.0.0, there is nothing before. + if (stageNo === 0) return new GameStage(); + + stepNo = normStage.step; + seqObj = this.stager.sequence[stageNo - 1]; + + execLoops = 'undefined' === typeof execLoops ? true : execLoops; + + // Within same stage. + + // Handle stepping. + if (stepNo > 1) { + return new GameStage({ + stage: stageNo, + step: stepNo - 1, + round: normStage.round + }); + } + + // Handle rounds: + if (normStage.round > 1) { + return new GameStage({ + stage: stageNo, + step: seqObj.steps.length, + round: normStage.round - 1 + }); + } + + // Handle beginning (0.0.0). + if (stageNo === 1) return new GameStage(); + + // Go to previous stage. + + // Get previous sequence object: + prevSeqObj = this.stager.sequence[stageNo - 2]; + + // Return null if a loop is found and can't be executed. + if (!execLoops && seqObj.type === 'loop') return null; + + // Skip over loops if their callbacks return false: + while (prevSeqObj.type === 'loop' && + !prevSeqObj.cb.call(this.node.game)) { + + stageNo--; + // (0.0.0). + if (stageNo <= 1) return new GameStage(); + + // Update seq object. + prevSeqObj = this.stager.sequence[stageNo - 2]; + } + + // Get number of steps in previous stage: + prevStepNo = prevSeqObj.steps.length; + + // Handle repeat block: + if (prevSeqObj.type === 'repeat') { + return new GameStage({ + stage: stageNo - 1, + step: prevStepNo, + round: prevSeqObj.num + }); + } + + // Handle normal blocks: + return new GameStage({ + stage: stageNo - 1, + step: prevStepNo, + round: 1 + }); + }; + + /** + * ### GamePlot.jump + * + * Returns a distant stage in the stager + * + * Works with negative delta only in simple mode. + * + * Uses `GamePlot.previous` and `GamePlot.next` for stepping. + * + * @param {GameStage} curStage The GameStage of reference + * @param {number} delta The offset. Negative number for backward stepping. + * @param {bolean} execLoops Optional. If true, loop and doLoop + * conditional function will be executed to determine next stage. + * If false, null will be returned when a loop or doLoop is found + * and more evaluations are still required. Default: true. + * + * @return {GameStage|string|null} The distant game stage + * + * @see GameStage + * @see GamePlot.previous + * @see GamePlot.next + */ + GamePlot.prototype.jump = function(curStage, delta, execLoops) { + var stageType; + execLoops = 'undefined' === typeof execLoops ? true : execLoops; + if (delta < 0) { + while (delta < 0) { + curStage = this.previous(curStage, execLoops); + + if (!(curStage instanceof GameStage) || curStage.stage === 0) { + return curStage; + } + delta++; + if (!execLoops) { + // If there are more steps to jump, check if we have loops. + stageType = this.stager.sequence[curStage.stage -1].type; + if (stageType === 'loop') { + if (delta < 0) return null; + } + else if (stageType === 'doLoop') { + if (delta < -1) return null; + else return curStage; + } + } + } + } + else { + while (delta > 0) { + curStage = this.next(curStage, execLoops); + // If we find a loop return null. + if (!(curStage instanceof GameStage)) return curStage; + + delta--; + if (!execLoops) { + // If there are more steps to jump, check if we have loops. + stageType = this.stager.sequence[curStage.stage -1].type; + if (stageType === 'loop' || stageType === 'doLoop') { + if (delta > 0) return null; + else return curStage; + } + } + } + } + + return curStage; + }; + + /** + * ### GamePlot.stepsToNextStage + * + * Returns the number of steps to reach the next stage + * + * By default, each stage repetition is considered as a new stage. + * + * @param {GameStage|string} gameStage The reference step + * @param {boolean} countRepeat If TRUE stage repetitions are + * considered as current stage, and included in the count. Default: FALSE. + * + * @return {number|null} The number of steps including current one, + * or NULL on error. + * + * @see GamePlot.normalizeGameStage + */ + GamePlot.prototype.stepsToNextStage = function(gameStage, countRepeat) { + var seqObj, totSteps, stepNo; + if (!this.stager) return null; + + // Checks stage and step ranges. + gameStage = this.normalizeGameStage(gameStage); + if (!gameStage) return null; + if (gameStage.stage === 0) return 1; + seqObj = this.getSequenceObject(gameStage); + if (!seqObj) return null; + stepNo = gameStage.step; + totSteps = seqObj.steps.length; + if (countRepeat) { + if (seqObj.type === 'repeat') { + if (gameStage.round > 1) { + stepNo = ((gameStage.round-1) * totSteps) + stepNo; + } + totSteps = totSteps * seqObj.num; + } + else if (seqObj.type === 'loop' || seqObj.type === 'doLoop') { + return null; + } + } + return 1 + totSteps - stepNo; + }; + + // TODO: remove in next version. + GamePlot.prototype.stepsToPreviousStage = function(gameStage) { + console.log('GamePlot.stepsToPreviousStage is **deprecated**. Use' + + 'GamePlot.stepsFromPreviousStage instead.'); + return this.stepsFromPreviousStage(gameStage); + }; + + /** + * ### GamePlot.stepsFromPreviousStage + * + * Returns the number of steps passed from the previous stage + * + * By default, each stage repetition is considered as a new stage. + * + * @param {GameStage|string} gameStage The reference step + * @param {boolean} countRepeat If TRUE stage repetitions are + * considered as current stage, and included in the count. Default: FALSE. + * + * @return {number|null} The number of steps including current one, or + * NULL on error. + * + * @see GamePlot.normalizeGameStage + */ + GamePlot.prototype.stepsFromPreviousStage = function(gameStage, + countRepeat) { + + var seqObj, stepNo; + if (!this.stager) return null; + + // Checks stage and step ranges. + gameStage = this.normalizeGameStage(gameStage); + if (!gameStage || gameStage.stage === 0) return null; + seqObj = this.getSequenceObject(gameStage); + if (!seqObj) return null; + stepNo = gameStage.step; + if (countRepeat) { + if (seqObj.type === 'repeat') { + if (gameStage.round > 1) { + stepNo = (seqObj.steps.length * (gameStage.round-1)) + + stepNo; + } + } + else if (seqObj.type === 'loop' || seqObj.type === 'doLoop') { + return null; + } + } + return stepNo; + }; + + /** + * ### GamePlot.getSequenceObject + * + * Returns the sequence object corresponding to a GameStage + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * + * @return {object|null} The corresponding sequence object, + * or NULL if not found + */ + GamePlot.prototype.getSequenceObject = function(gameStage) { + if (!this.stager) return null; + gameStage = this.normalizeGameStage(gameStage); + return gameStage ? this.stager.sequence[gameStage.stage - 1] : null; + }; + + /** + * ### GamePlot.getStage + * + * Returns the stage object corresponding to a GameStage + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * + * @return {object|null} The corresponding stage object, or NULL + * if the step was not found + */ + GamePlot.prototype.getStage = function(gameStage) { + var stageObj; + if (!this.stager) return null; + gameStage = this.normalizeGameStage(gameStage); + if (gameStage) { + stageObj = this.stager.sequence[gameStage.stage - 1]; + stageObj = stageObj ? this.stager.stages[stageObj.id] : null; + } + return stageObj || null; + }; + + /** + * ### GamePlot.getStep + * + * Returns the step object corresponding to a GameStage + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * + * @return {object|null} The corresponding step object, or NULL + * if the step was not found + */ + GamePlot.prototype.getStep = function(gameStage) { + var seqObj, stepObj; + if (!this.stager) return null; + // Game stage is normalized inside getSequenceObject. + seqObj = this.getSequenceObject(gameStage); + if (seqObj) { + stepObj = this.stager.steps[seqObj.steps[gameStage.step - 1]]; + } + return stepObj || null; + }; + + /** + * ### GamePlot.getStepRule + * + * Returns the step-rule function for a given game-stage + * + * Otherwise, the order of lookup is: + * + * 1. step object + * 2. stage object + * 3. default property + * 4. default step-rule of the Stager object + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * + * @return {function} The step-rule function or the default rule + * + * @see Stager.getDefaultStepRule + */ + GamePlot.prototype.getStepRule = function(gameStage) { + var rule; + rule = this.getProperty(gameStage, 'stepRule'); + if ('string' === typeof rule) rule = parent.stepRules[rule]; + return rule || this.stager.getDefaultStepRule(); + }; + + /** + * ### GamePlot.getGlobal + * + * Looks up the value of a global variable + * + * Looks for definitions of a global variable in + * + * 1. the globals property of the step object of the given gameStage, + * + * 2. the globals property of the stage object of the given gameStage, + * + * 3. the defaults, defined in the Stager. + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * @param {string} globalVar The name of the global variable + * + * @return {mixed|null} The value of the global variable if found, + * NULL otherwise. + */ + GamePlot.prototype.getGlobal = function(gameStage, globalVar) { + var stepObj, stageObj; + var stepGlobals, stageGlobals, defaultGlobals; + + gameStage = new GameStage(gameStage); + + // Look in current step: + stepObj = this.getStep(gameStage); + if (stepObj) { + stepGlobals = stepObj.globals; + if (stepGlobals && stepGlobals.hasOwnProperty(globalVar)) { + return stepGlobals[globalVar]; + } + } + + // Look in current stage: + stageObj = this.getStage(gameStage); + if (stageObj) { + stageGlobals = stageObj.globals; + if (stageGlobals && stageGlobals.hasOwnProperty(globalVar)) { + return stageGlobals[globalVar]; + } + } + + // Look in Stager's defaults: + if (this.stager) { + defaultGlobals = this.stager.getDefaultGlobals(); + if (defaultGlobals && defaultGlobals.hasOwnProperty(globalVar)) { + return defaultGlobals[globalVar]; + } + } + + // Not found: + return null; + }; + + /** + * ### GamePlot.getGlobals + * + * Looks up and build the _globals_ object for the specified game stage + * + * Globals properties are mixed in at each level (defaults, stage, step) + * to form the complete set of globals available for the specified + * game stage. + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * + * @return {object} The _globals_ object for the specified game stage + */ + GamePlot.prototype.getGlobals = function(gameStage) { + var stepstage, globals; + if ('string' !== typeof gameStage && 'object' !== typeof gameStage) { + throw new TypeError('GamePlot.getGlobals: gameStage must be ' + + 'string or object.'); + } + globals = {}; + // No stager found, no globals! + if (!this.stager) return globals; + + // Look in Stager's defaults: + J.mixin(globals, this.stager.getDefaultGlobals()); + + // Look in current stage: + stepstage = this.getStage(gameStage); + if (stepstage) J.mixin(globals, stepstage.globals); + + // Look in current step: + stepstage = this.getStep(gameStage); + if (stepstage) J.mixin(globals, stepstage.globals); + + return globals; + }; + + /** + * ### GamePlot.getProperty + * + * Looks up the value of a property in a hierarchy of lookup locations + * + * The hierarchy of lookup locations is: + * + * 1. the temporary cache, if game stage equals current game stage + * 2. the game plot cache + * 3. the step object of the given gameStage, + * 4. the stage object of the given gameStage, + * 5. the defaults, defined in the Stager. + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * @param {string} prop The name of the property + * @param {mixed} notFound Optional. A value to return if + * property is not found. Default: NULL + * @param {object} mask Optional. An object disabling specific lookup + * locations. Default: + * ``` + * { tmpCache: false, cache: false, step: false, stage: false, game: false } + * ``` + * + * @return {mixed|null} The value of the property if found, NULL otherwise. + * + * @see GamePlot.cache + */ + GamePlot.prototype.getProperty = function(gameStage, prop, notFound, mask) { + + var stepObj, stageObj, defaultProps, found, res; + + if ('string' !== typeof prop) { + throw new TypeError('GamePlot.getProperty: property must be ' + + 'string. Found: ' + prop); + } + + gameStage = new GameStage(gameStage); + + mask = mask || {}; + if ('object' !== typeof mask) { + throw new TypeError('GamePlot.getProperty: mask must be ' + + 'object or undefined. Found: ' + mask); + } + + // Look in the tmpCache (cleared every step). + if (!mask.tmpCache && this.tmpCache.hasOwnProperty(prop) && + GameStage.compare(gameStage,this.node.player.stage) === 0) { + + return this.tmpCache(prop); + } + + // Look in the main cache (this persists over steps). + if (!mask.tmpCache && this.cache[gameStage] && + this.cache[gameStage].hasOwnProperty(prop)) { + + return this.cache[gameStage][prop]; + } + + // Look in current step. + if (!mask.step) { + stepObj = this.getStep(gameStage); + if (stepObj && stepObj.hasOwnProperty(prop)) { + res = stepObj[prop]; + found = true; + } + } + + // Look in current stage. + if (!found && !mask.stage) { + stageObj = this.getStage(gameStage); + if (stageObj && stageObj.hasOwnProperty(prop)) { + res = stageObj[prop]; + found = true; + } + } + + // Look in Stager's defaults. + if (!found && !mask.game && this.stager) { + defaultProps = this.stager.getDefaultProperties(); + if (defaultProps && defaultProps.hasOwnProperty(prop)) { + res = defaultProps[prop]; + found = true; + } + } + + // Cache it and return it. + if (found) { + cacheStepProperty(this, gameStage, prop, res); + return res; + } + + // Return notFound. + return 'undefined' === typeof notFound ? null : notFound; + }; + + + /** + * ### GamePlot.updateProperty + * + * Looks up a property and updates it to the new value + * + * Look up follows the steps described in _GamePlot.getProperty_, + * excluding step 1. If a property is found and updated, its value + * is stored in the cached. + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * @param {string} property The name of the property + * @param {mixed} value The new value for the property. + * + * @return {bool} TRUE, if property is found and updated, FALSE otherwise. + * + * @see GamePlot.cache + */ + GamePlot.prototype.updateProperty = function(gameStage, property, value) { + var stepObj, stageObj, defaultProps, found; + + gameStage = new GameStage(gameStage); + + if ('string' !== typeof property) { + throw new TypeError('GamePlot.updateProperty: property must be ' + + 'string. Found: ' + property); + } + + // Look in current step. + stepObj = this.getStep(gameStage); + if (stepObj && stepObj.hasOwnProperty(property)) { + stepObj[property] = value; + found = true; + } + + // Look in current stage. + if (!found) { + stageObj = this.getStage(gameStage); + if (stageObj && stageObj.hasOwnProperty(property)) { + stageObj[property] = value; + found = true; + } + } + + // Look in Stager's defaults. + if (!found && this.stager) { + defaultProps = this.stager.getDefaultProperties(); + if (defaultProps && defaultProps.hasOwnProperty(property)) { + defaultProps[property] = value; + found = true; + } + } + + // Cache it and return it. + if (found) { + cacheStepProperty(this, gameStage, property, value); + return true; + } + + // Not found. + return false; + }; + + /** + * ### GamePlot.setStepProperty + * + * Sets the value a property in a step object + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * @param {string} property The name of the property + * @param {mixed} value The new value for the property. + * + * @return {bool} TRUE, if property is found and updated, FALSE otherwise. + * + * @see GamePlot.cache + */ + GamePlot.prototype.setStepProperty = function(gameStage, property, value) { + var stepObj; + + gameStage = new GameStage(gameStage); + + if ('string' !== typeof property) { + throw new TypeError('GamePlot.setStepProperty: property must be ' + + 'string'); + } + + // Get step. + stepObj = this.getStep(gameStage); + + if (stepObj) { + stepObj[property] = value; + // Cache it. + cacheStepProperty(this, gameStage, property, value); + return true; + } + + return false; + }; + + /** + * ### GamePlot.setStageProperty + * + * Sets the value a property in a step object + * + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * @param {string} property The name of the property + * @param {mixed} value The new value for the property. + * + * @return {bool} TRUE, if property is found and updated, FALSE otherwise. + * + * @see GamePlot.cache + */ + GamePlot.prototype.setStageProperty = function(gameStage, property, value) { + var stageObj; + + gameStage = new GameStage(gameStage); + + if ('string' !== typeof property) { + throw new TypeError('GamePlot.setStageProperty: property must be ' + + 'string'); + } + + // Get stage. + stageObj = this.getStage(gameStage); + + if (stageObj) { + stageObj[property] = value; + return true; + } + + return false; + }; + + /** + * ### GamePlot.isReady + * + * Returns whether the stager has any content + * + * @return {boolean} FALSE if stager is empty, TRUE otherwise + */ + GamePlot.prototype.isReady = function() { + return this.stager && + (this.stager.sequence.length > 0 || + this.stager.generalNextFunction !== null || + !J.isEmpty(this.stager.nextFunctions)); + }; + + /** + * ### GamePlot.normalizeGameStage + * + * Converts the GameStage fields to numbers + * + * Checks if stage and step numbers are within the range + * of what found in the stager. + * + * Works only in simple mode. + * + * @param {GameStage|string} gameStage The GameStage object + * + * @return {GameStage|null} The normalized GameStage object; NULL on error + */ + GamePlot.prototype.normalizeGameStage = function(gameStage) { + var stageNo, stageObj, stepNo, seqIdx, seqObj; + var gs; + + if (this.isFlexibleMode()) { + throw new Error('GamePlot.normalizeGameStage: invalid call in ' + + 'flexible sequence.') + } + + // If already normalized and in cache, return it. + if ('string' === typeof gameStage) { + if (this._normalizedCache[gameStage]) { + return this._normalizedCache[gameStage]; + } + } + + gs = new GameStage(gameStage); + + // Find stage number. + if ('number' === typeof gs.stage) { + if (gs.stage === 0) return new GameStage(); + stageNo = gs.stage; + } + else if ('string' === typeof gs.stage) { + if (gs.stage === GamePlot.GAMEOVER || + gs.stage === GamePlot.END_SEQ || + gs.stage === GamePlot.NO_SEQ) { + + return null; + } + + for (seqIdx = 0; seqIdx < this.stager.sequence.length; seqIdx++) { + if (this.stager.sequence[seqIdx].id === gs.stage) { + break; + } + } + stageNo = seqIdx + 1; + } + else { + throw new Error('GamePlot.normalizeGameStage: gameStage.stage ' + + 'must be number or string: ' + + (typeof gs.stage)); + } + + if (stageNo < 1 || stageNo > this.stager.sequence.length) { + this.node.silly('GamePlot.normalizeGameStage: non-existent ' + + 'stage: ' + gs.stage); + return null; + } + + // Get sequence object. + seqObj = this.stager.sequence[stageNo - 1]; + if (!seqObj) return null; + + if (seqObj.type === 'gameover') { + return new GameStage({ + stage: stageNo, + step: 1, + round: gs.round + }); + } + + // Get stage object. + stageObj = this.stager.stages[seqObj.id]; + if (!stageObj) return null; + + // Find step number. + if ('number' === typeof gs.step) { + stepNo = gs.step; + } + else if ('string' === typeof gs.step) { + stepNo = seqObj.steps.indexOf(gs.step) + 1; + } + else { + throw new Error('GamePlot.normalizeGameStage: gameStage.step ' + + 'must be number or string: ' + + (typeof gs.step)); + } + + if (stepNo < 1 || stepNo > stageObj.steps.length) { + this.node.silly('normalizeGameStage non-existent step: ' + + stageObj.id + '.' + gs.step); + return null; + } + + // Check round property. + if ('number' !== typeof gs.round) return null; + + gs = new GameStage({ + stage: stageNo, + step: stepNo, + round: gs.round + }); + + if ('string' === typeof gameStage) { + this._normalizedCache[gameStage] = gs; + } + + return gs; + }; + + /** + * ### GamePlot.isFlexibleMode + * + * Returns TRUE if operating in _flexible_ mode + * + * In _flexible_ mode the next step to be executed is decided by a + * a callback function. + * + * In standard mode all steps are already inserted in a sequence. + * + * @return {boolean} TRUE if flexible mode is on + */ + GamePlot.prototype.isFlexibleMode = function() { + return this.stager.sequence.length === 0; + }; + + /** + * ### GamePlot.getRound + * + * Returns the current/remaining/past/total round number in a game stage + * + * @param {mixed} gs The game stage of reference + * @param {string} mod Optional. Modifies the return value. + * + * - 'current': current round number (default) + * - 'total': total number of rounds + * - 'remaining': number of rounds remaining (excluding current round) + * - 'past': number of rounds already past (excluding current round) + * + * @return {number|null} The requested information, or null if + * the number of rounds is not known (e.g. if the stage is a loop) + * + * @see GamePlot.getSequenceObject + */ + GamePlot.prototype.getRound = function(gs, mod) { + var seqObj; + gs = new GameStage(gs); + if (gs.stage === 0) return null; + + seqObj = this.getSequenceObject(gs); + if (!seqObj) return null; + + if (!mod || mod === 'current') return gs.round; + if (mod === 'past') return gs.round - 1; + + if (mod === 'total') { + if (seqObj.type === 'repeat') return seqObj.num; + else if (seqObj.type === 'plain') return 1; + else return null; + } + if (mod === 'remaining') { + if (seqObj.type === 'repeat') return seqObj.num - gs.round; + else if (seqObj.type === 'plain') return 1; + else return null; + } + + throw new TypeError('GamePlot.getRound: mod must be a known string ' + + 'or undefined. Found: ' + mod); + }; + + // ## Helper Methods + + /** + * ### cacheStepProperty + * + * Sets the value of a property in the cache + * + * Parameters are not checked + * + * @param {GamePlot} that The game plot instance + * @param {GameStage|string} gameStage The GameStage object, + * or its string representation + * @param {string} property The name of the property + * @param {mixed} value The value of the property + * + * @see GamePlot.cache + * + * @api private + */ + function cacheStepProperty(that, gameStage, property, value) { + if (!that.cache[gameStage]) that.cache[gameStage] = {}; + that.cache[gameStage][property] = value; + } + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # GameMsgGenerator + * + * Copyright(c) 2015 Stefano Balietti + * MIT Licensed + * + * `nodeGame` component rensponsible creating messages + * + * Static factory of objects of type `GameMsg`. + * + * @see GameMsg + * @see node.target + * @see node.action + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + + exports.GameMsgGenerator = GameMsgGenerator; + + var GameMsg = parent.GameMsg, + GameStage = parent.GameStage, + constants = parent.constants; + + /** + * ## GameMsgGenerator constructor + * + * Creates an instance of GameMSgGenerator + * + */ + function GameMsgGenerator(node) { + this.node = node; + } + + // ## GameMsgGenerator methods + + /** + * ### GameMsgGenerator.create + * + * Primitive for creating a new GameMsg object + * + * Decorates an input object with all the missing properties + * of a full GameMsg object. + * + * By default GAMECOMMAND, REDIRECT, PCONNET, PDISCONNECT, PRECONNECT + * have priority 1, all the other targets have priority 0. + * + * @param {object} msg Optional. The init object + * + * @return {GameMsg} The full GameMsg object + * + * @see GameMsg + */ + GameMsgGenerator.prototype.create = function(msg) { + var gameStage, priority, node; + node = this.node; + + if (msg.stage) { + gameStage = msg.stage; + } + else { + gameStage = node.game ? + node.game.getCurrentGameStage() : new GameStage('0.0.0'); + } + + if ('undefined' !== typeof msg.priority) { + priority = msg.priority; + } + else if (msg.target === constants.target.GAMECOMMAND || + msg.target === constants.target.REDIRECT || + msg.target === constants.target.PCONNECT || + msg.target === constants.target.PDISCONNECT || + msg.target === constants.target.PRECONNECT || + msg.target === constants.target.SERVERCOMMAND || + msg.target === constants.target.SETUP) { + + priority = 1; + } + else { + priority = 0; + } + + return new GameMsg({ + session: 'undefined' !== typeof msg.session ? + msg.session : node.socket.session, + stage: gameStage, + action: msg.action || constants.action.SAY, + target: msg.target || constants.target.DATA, + from: node.player ? node.player.id : constants.UNDEFINED_PLAYER, + to: 'undefined' !== typeof msg.to ? msg.to : 'SERVER', + text: 'undefined' !== typeof msg.text ? "" + msg.text : null, + data: 'undefined' !== typeof msg.data ? msg.data : {}, + priority: priority, + reliable: msg.reliable || 1 + }); + + }; + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # PushManager + * + * Push players to advance to next step, otherwise disconnects them. + * + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + exports.PushManager = PushManager; + + var GameStage = parent.GameStage; + + var DONE = parent.constants.stageLevels.DONE; + var PUSH_STEP = parent.constants.gamecommands.push_step; + var GAMECOMMAND = parent.constants.target.GAMECOMMAND; + + PushManager.offsetWaitTime = 5000; + PushManager.replyWaitTime = 2000; + PushManager.checkPushWaitTime = 2000; + + /** + * ## PushManager constructor + * + * Creates a new instance of PushManager + * + * @param {NodeGameClient} node A nodegame-client instance + * @param {object} options Optional. Configuration options + */ + function PushManager(node, options) { + + /** + * ### PushManager.node + * + * Reference to a nodegame-client instance + */ + this.node = node; + + /** + * ### PushManager.timer + * + * The timer object that will fire the checking of clients + * + * The timer will be created only if needed. + * + * @see PushManager.startTimer + */ + this.timer = null; + + /** + * ### PushManager.offsetWaitTime + * + * Time that is always added to the timer value of + * + * @see PushManager.startTimer + */ + this.offsetWaitTime = PushManager.offsetWaitTime; + + /** + * ### PushManager.replyWaitTime + * + * Time to wait to get a reply from a pushed client + * + * @see PushManager.pushGame + */ + this.replyWaitTime = PushManager.replyWaitTime; + + /** + * ### PushManager.checkPushWaitTime + * + * Time to wait to check if a pushed client updated its state + * + * @see PushManager.pushGame + */ + this.checkPushWaitTime = PushManager.checkPushWaitTime; + + this.init(options); + } + + /** + * ### PushManager.init + * + * Inits the configuration for the instance + * + * @param {object} Optional. Configuration object + * + * @see checkAndAssignAllWaitTimes + */ + PushManager.prototype.init = function(options) { + options = options || {}; + checkAndAssignAllWaitTimes('init', options, this); + }; + + /** + * ## PushManager.startTimer + * + * Sets a timer for checking if all clients have finished current step + * + * The duration of the timer is specified by parameter conf.offset. + * (Default this.offsetWaitTime). Other options in configuration + * parameter are passed to `PushManager.pushGame`, which is called + * if timer expires. + * + * Calling startTimer on a running timer will clear previous one, + * and create a new one. + * + * @param {boolean|object} conf Optional. Configuration object passed + * to `pushGame` method. + * + * @see PushManager.offsetWaitTime + * @see PushManager.pushGame + * @see GameTimer.parseMilliseconds + */ + PushManager.prototype.startTimer = function(conf) { + var stage, that, offset; + var node; + + // Adjust user input. + if (conf === true || 'undefined' === typeof conf) { + conf = {}; + } + else if ('object' !== typeof conf) { + throw new TypeError('PushManager.startTimer: conf must be ' + + 'object, TRUE, or undefined. Found: ' + conf); + } + + node = this.node; + + if (!this.timer) { + this.timer = node.timer.createTimer({ + name: 'push_clients', + validity: 'game' + }); + } + else { + this.clearTimer(); + } + + if ('undefined' !== typeof conf.offset) { + offset = node.timer.parseInput('offset', conf.offset); + } + else { + offset = this.offsetWaitTime; + } + + // Cloning current stage. + stage = { + stage: node.player.stage.stage, + step: node.player.stage.step, + round: node.player.stage.round + }; + + node.info('push-manager: starting timer with offset ' + offset); + + that = this; + + // Make sure milliseconds and update are the same. + this.timer.init({ + milliseconds: offset, + update: offset, + timeup: function() { that.pushGame.call(that, stage, conf); }, + }); + this.timer.start(); + }; + + /** + * ## PushManager.clearTimer + * + * Clears timer for checking if all clients have finished current step + * + * This function is normally called at every new step. + * + * @see PushManager.startTimer + * @see Game.gotoStep + */ + PushManager.prototype.clearTimer = function() { + if (this.timer && !this.timer.isStopped()) { + this.node.silly('push-manager: timer cleared.'); + // console.log('push-manager: timer cleared.'); + this.timer.stop(); + } + }; + + /** + * ## PushManager.isActive + * + * Returns TRUE if timer is running + */ + PushManager.prototype.isActive = function() { + return !this.timer.isStopped(); + }; + + /** + * ### PushManager.pushGame + * + * Pushes any client that is connected, but not DONE, to step forward + * + * It sends a GET message to all clients whose stage level is not + * marked as DONE (100), and waits for the reply. If the reply does + * not arrive it will disconnect them. If the reply arrives, it will + * later check if they manage to step, and if not disconnects them. + * + * @param {object} stage The stage to check + * @param {object} conf Optional. Configuration options. + * + * @see checkIfPushWorked + */ + PushManager.prototype.pushGame = function(stage, conf) { + var m, node, replyWaitTime, checkPushWaitTime; + node = this.node; + + node.info('push-manager: checking clients'); + + if ('object' === typeof conf) { + m = 'pushGame'; + replyWaitTime = checkAndAssignWaitTime(m, conf, 'reply', conf); + checkPushWaitTime = checkAndAssignWaitTime(m, conf, 'check', conf); + } + if ('undefined' === typeof replyWaitTime) { + replyWaitTime = this.replyWaitTime; + } + if ('undefined' === typeof checkPushWaitTime) { + checkPushWaitTime = this.checkPushWaitTime; + } + + node.game.pl.each(function(p) { + + // A client is not DONE and it is still in the same stage level. + if (p.stageLevel !== DONE && + GameStage.compare(p.stage, stage) === 0) { + + // console.log('push needed: ', p.id); + node.warn('push-manager: push needed: ' + p.id); + // Send push. + node.get(PUSH_STEP, + function(value) { + checkIfPushWorked(node, p, stage, + checkPushWaitTime); + }, + p.id, { + timeout: replyWaitTime, + executeOnce: true, + target: GAMECOMMAND, + timeoutCb: function() { + forceDisconnect(node, p); + } + }); + } + }); + }; + + // ## Helper methods + + /** + * ### checkIfPushWorked + * + * Checks whether the stage of a client has changed after + * + * @param {NodeGameClient} node The node instance used to send msg + * @param {object} p The player object containing info about id and sid + * @param {GameStage} stage The stage to check + * @param {number} milliseconds Optional The number of milliseconds to + * wait before checking again the stage of a client. Default 0. + */ + function checkIfPushWorked(node, p, stage, milliseconds) { + + node.info('push-manager: received reply from ' + p.id); + + setTimeout(function() { + var pp; + if (node.game.pl.exist(p.id)) { + pp = node.game.pl.get(p.id); + + // Client could have moved to next step, or be DONE + // waiting for a command from server. + if (GameStage.compare(pp.stage, stage) !== 0 || + pp.stageLevel === DONE) { + + node.info('push-manager: push worked for ' + p.id); + } + else { + forceDisconnect(node, pp); + } + } + }, milliseconds || 0); + } + + /** + * ### forceDisconnect + * + * Disconnects one player by sending a DISCONNECT msg to server + * + * @param {NodeGameClient} node The node instance used to send msg + * @param {object} p The player object containing info about id and sid + */ + function forceDisconnect(node, p) { + var msg; + // No reply to GET, disconnect client. + node.warn('push-manager: disconnecting ' + p.id); + // console.log('push-manager: disconnecting: ' + p.id); + msg = node.msg.create({ + target: 'SERVERCOMMAND', + text: 'DISCONNECT', + data: { + id: p.id, + sid: p.sid + } + }); + node.socket.send(msg); + } + + /** + * ### checkAndAssignWaitTime + * + * Checks if a valid wait time is found in options object, if so assigns it + * + * Option name is first tried as it is, and if not found, 'WaitTime' + * is appended, and check if performed again. + * + * If set, properties must be positive numbers, otherwise an error is + * thrown. + * + * @param {string} method Then name of the method invoking the function + * @param {object} options Configuration options + * @param {string} name The name of the option to check and assign. + * If the option is not defined, it appends 'WaitTime', and tries again. + * @param {object} that The instance to which assign the correct value + * + * @return {number} The validated number, or undefined if not set + */ + function checkAndAssignWaitTime(method, options, name, that) { + var n; + n = options[name]; + if ('undefined' !== typeof n) { + name = name + 'WaitTime'; + n = options[name]; + } + if ('undefined' !== typeof n) { + if ('number' !== typeof n || n < 0) { + throw new TypeError('PushManager.' + method + ': options.' + + name + 'must be a positive number. ' + + 'Found: ' + n); + } + that[name] = n; + return n; + } + } + + /** + * ### checkAndAssignAllWaitTimes + * + * Validates properties 'offset', 'reply', and 'check' of an object + * + * @param {string} method Then name of the method invoking the function + * @param {object} options Configuration options + * @param {object} that The instance to which assign the correct value + * + * @see PushManager.init + * @see checkAndAssignWaitTime + */ + function checkAndAssignAllWaitTimes(method, options, that) { + checkAndAssignWaitTime(method, options, 'offset', that); + checkAndAssignWaitTime(method, options, 'reply', that); + checkAndAssignWaitTime(method, options, 'check', that); + } +})( + 'undefined' !== typeof node ? node : module.exports, + 'undefined' !== typeof node ? node : module.parent.exports +); + +/** + * # SizeManager + * Copyright(c) 2016 Stefano Balietti + * MIT Licensed + * + * Handles changes in the number of connected players. + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + + // Exposing SizeManager constructor + exports.SizeManager = SizeManager; + + var J = parent.JSUS; + + /** + * ## SizeManager constructor + * + * Creates a new instance of SizeManager + * + * @param {NodeGameClient} node A valid NodeGameClient object + */ + function SizeManager(node) { + + /** + * ### SizeManager.node + * + * Reference to a nodegame-client instance + */ + this.node = node; + + /** + * ### SizeManager.checkSize + * + * Checks if the current number of players is right + * + * This function is recreated each step based on the values + * of properties `min|max|exactPlayers` found in the Stager. + * + * It is used by `Game.shouldStep` to determine if we go to the + * next step. + * + * Unlike `SizeManager.changeHandler` this method does not + * accept parameters nor execute callbacks, just returns TRUE/FALSE. + * + * @return {boolean} TRUE if all checks are passed + * + * @see Game.shouldStep + * @see Game.shouldEmitPlaying + * @see SizeManager.init + * @see SizeManager.changeHandler + */ + this.checkSize = function() { return true; }; + + /** + * ### SizeManager.changeHandler + * + * Handles changes in the number of players + * + * This function is recreated each step based on the values + * of properties `min|max|exactPlayers` found in the Stager. + * + * Unlike `SizeManager.checkSize` this method requires input + * parameters and executes the appropriate callback functions + * in case a threshold is hit. + * + * @param {string} op The name of the operation: + * 'pdisconnect', 'pconnect', 'pupdate', 'replace' + * @param {Player|PlayerList} obj The object causing the update + * + * @return {boolean} TRUE, if no player threshold is passed + * + * @see SizeManager.min|max|exactPlayers + * @see SizeManager.min|max|exactCbCalled + * @see SizeManager.init + * @see SizeManager.checkSize + */ + this.changeHandler = function(op, obj) { return true; }; + + /** + * ### SizeManager.minThresold + * + * The min-players threshold currently set + */ + this.minThresold = null; + + /** + * ### SizeManager.minCb + * + * The callback to execute once the min threshold is hit + */ + this.minCb = null; + + /** + * ### SizeManager.minCb + * + * The callback to execute once the min threshold is restored + */ + this.minRecoveryCb = null; + + /** + * ### SizeManager.maxThreshold + * + * The max-players threshold currently set + */ + this.maxThreshold = null; + + /** + * ### SizeManager.minCbCalled + * + * TRUE, if the minimum-player callback has already been called + * + * This is reset when the max-condition is satisfied again. + * + * @see SizeManager.changeHandler + */ + this.minCbCalled = false; + + /** + * ### SizeManager.maxCb + * + * The callback to execute once the max threshold is hit + */ + this.maxCb = null; + + /** + * ### SizeManager.maxCb + * + * The callback to execute once the max threshold is restored + */ + this.maxRecoveryCb = null; + + /** + * ### SizeManager.maxCbCalled + * + * TRUE, if the maximum-player callback has already been called + * + * This is reset when the max-condition is satisfied again. + * + * @see SizeManager.changeHandler + */ + this.maxCbCalled = false; + + /** + * ### SizeManager.exactThreshold + * + * The exact-players threshold currently set + */ + this.exactThreshold = null; + + /** + * ### SizeManager.exactCb + * + * The callback to execute once the exact threshold is hit + */ + this.exactCb = null; + + /** + * ### SizeManager.exactCb + * + * The callback to execute once the exact threshold is restored + */ + this.exactRecoveryCb = null; + + /** + * ### SizeManager.exactCbCalled + * + * TRUE, if the exact-player callback has already been called + * + * This is reset when the exact-condition is satisfied again. + * + * @see SizeManager.changeHandler + */ + this.exactCbCalled = false; + } + + /** + * ### SizeManager.init + * + * Sets all internal references to null + * + * @see SizeManager.init + */ + SizeManager.prototype.clear = function() { + this.minThreshold = null; + this.minCb = null; + this.minRecoveryCb = null; + this.minCbCalled = false; + + this.maxThreshold = null; + this.maxCb = null; + this.maxRecoveryCb = null; + this.maxCbCalled = false; + + this.exactThreshold = null; + this.exactCb = null; + this.exactRecoveryCb = null; + this.exactCbCalled = false; + + this.changeHandler = function(op, obj) { return true; }; + this.checkSize = function() { return true; }; + }; + + /** + * ### SizeManager.init + * + * Evaluates the requirements for the step and store references internally + * + * If required, it adds a listener to changes in the size of player list. + * + * At the beginning, calls `SizeManager.clear` + * + * @param {GameStage} step Optional. The step to evaluate. + * Default: node.player.stage + * + * @return {boolean} TRUE if a full handler was added + * + * @see SizeManager.changeHandlerFull + * @see SizeManager.clear + */ + SizeManager.prototype.init = function(step) { + var node, property, doPlChangeHandler; + + this.clear(); + + node = this.node; + step = step || node.player.stage; + property = node.game.plot.getProperty(step, 'minPlayers'); + if (property) { + this.setHandler('min', property); + doPlChangeHandler = true; + } + + property = node.game.plot.getProperty(step, 'maxPlayers'); + if (property) { + this.setHandler('max', property); + + if (this.minThreshold === '*') { + throw new Error('SizeManager.init: maxPlayers cannot be' + + '"*" if minPlayers is "*"'); + } + + if (this.maxThreshold <= this.minThreshold) { + throw new Error('SizeManager.init: maxPlayers must be ' + + 'greater than minPlayers: ' + + this.maxThreshold + '<=' + this.minThreshold); + } + + doPlChangeHandler = true; + } + + property = node.game.plot.getProperty(step, 'exactPlayers'); + if (property) { + if (doPlChangeHandler) { + throw new Error('SizeManager.init: exactPlayers ' + + 'cannot be set if either minPlayers or ' + + 'maxPlayers is set.'); + } + this.setHandler('exact', property); + doPlChangeHandler = true; + } + + if (doPlChangeHandler) { + + this.changeHandler = this.changeHandlerFull; + // Maybe this should be a parameter. + // this.changeHandler('init'); + this.addListeners(); + + this.checkSize = this.checkSizeFull; + } + else { + // Set bounds-checking function. + this.checkSize = function() { return true; }; + this.changeHandler = function() { return true; }; + } + + return doPlChangeHandler; + }; + + /** + * ### SizeManager.checkSizeFull + * + * Implements SizeManager.checkSize + * + * @see SizeManager.checkSize + */ + SizeManager.prototype.checkSizeFull = function() { + var nPlayers, limit; + nPlayers = this.node.game.pl.size(); + + // Players should count themselves too. + if (!this.node.player.admin) nPlayers++; + + limit = this.minThreshold; + if (limit && limit !== '*' && nPlayers < limit) { + return false; + } + + limit = this.maxThreshold; + if (limit && limit !== '*' && nPlayers > limit) { + return false; + } + + limit = this.exacThreshold; + if (limit && limit !== '*' && nPlayers !== limit) { + return false; + } + + return true; + }; + + /** + * ### SizeManager.changeHandlerFull + * + * Implements SizeManager.changeHandler + * + * @see SizeManager.changeHandler + */ + SizeManager.prototype.changeHandlerFull = function(op, player) { + var threshold, cb, nPlayers; + var game, res; + + res = true; + game = this.node.game; + nPlayers = game.pl.size(); + // Players should count themselves too. + if (!this.node.player.admin) nPlayers++; + + threshold = this.minThreshold; + if (threshold) { + if (op === 'pdisconnect') { + if (threshold === '*' || nPlayers < threshold) { + + if (!this.minCbCalled) { + this.minCbCalled = true; + cb = game.getProperty('onWrongPlayerNum'); + + cb.call(game, 'min', this.minCb, player); + } + res = false; + } + } + else if (op === 'pconnect') { + if (this.minCbCalled) { + cb = game.getProperty('onCorrectPlayerNum'); + cb.call(game, 'min', this.minRecoveryCb, player); + } + // Must stay outside if. + this.minCbCalled = false; + } + } + + threshold = this.maxThreshold; + if (threshold) { + if (op === 'pconnect') { + if (threshold === '*' || nPlayers > threshold) { + + if (!this.maxCbCalled) { + this.maxCbCalled = true; + cb = game.getProperty('onWrongPlayerNum'); + cb.call(game, 'max', this.maxCb, player); + } + res = false; + } + } + else if (op === 'pdisconnect') { + if (this.maxCbCalled) { + cb = game.getProperty('onCorrectPlayerNum'); + cb.call(game, 'max', this.maxRecoveryCb, player); + } + // Must stay outside if. + this.maxCbCalled = false; + } + } + + threshold = this.exactThreshold; + if (threshold) { + if (nPlayers !== threshold) { + if (!this.exactCbCalled) { + this.exactCbCalled = true; + cb = game.getProperty('onWrongPlayerNum'); + cb.call(game, 'exact', this.exactCb, player); + } + res = false; + } + else { + if (this.exactCbCalled) { + cb = game.getProperty('onCorrectPlayerNum'); + cb.call(game, 'exact', this.exactRecoveryCb, player); + } + // Must stay outside if. + this.exactCbCalled = false; + } + } + + return res; + }; + + /** + * ### SizeManager.setHandler + * + * Sets the desired handler + * + * @param {string} type One of the available types: 'min', 'max', 'exact' + * @param {number|array} The value/s for the handler + */ + SizeManager.prototype.setHandler = function(type, values) { + values = checkMinMaxExactParams(type, values, this.node); + this[type + 'Threshold'] = values[0]; + this[type + 'Cb'] = values[1]; + this[type + 'RecoveryCb'] = values[2]; + }; + + /** + * ### SizeManager.addListeners + * + * Adds listeners to disconnect and connect to the `step` event manager + * + * Notice: PRECONNECT is not added and must handled manually. + * + * @see SizeManager.removeListeners + */ + SizeManager.prototype.addListeners = function() { + var that; + that = this; + this.node.events.step.on('in.say.PCONNECT', function(p) { + that.changeHandler('pconnect', p.data); + }, 'plManagerCon'); + this.node.events.step.on('in.say.PDISCONNECT', function(p) { + that.changeHandler('pdisconnect', p.data); + }, 'plManagerDis'); + }; + + /** + * ### SizeManager.removeListeners + * + * Removes the listeners to disconnect and connect + * + * Notice: PRECONNECT is not added and must handled manually. + * + * @see SizeManager.addListeners + */ + SizeManager.prototype.removeListeners = function() { + this.node.events.step.off('in.say.PCONNECT', 'plManagerCon'); + this.node.events.step.off('in.say.PDISCONNECT', 'plManagerDis'); + }; + + // ## Helper methods. + + /** + * ### checkMinMaxExactParams + * + * Checks the parameters of min|max|exactPlayers property of a step + * + * @param {string} name The name of the parameter: min|max|exact + * @param {number|array} property The property to check + * @param {NodeGameClient} node Reference to the node instance + * + * @see SizeManager.init + */ + function checkMinMaxExactParams(name, property, node) { + var num, cb, recoverCb, newArray; + + if ('number' === typeof property) { + newArray = true; + property = [property]; + } + else if (!J.isArray(property)) { + throw new TypeError('SizeManager.init: ' + name + + 'Players property must be number or ' + + 'non-empty array. Found: ' + property); + } + + num = property[0]; + cb = property[1] || null; + recoverCb = property[2] || null; + + if (num === '@') { + num = node.game.pl.size() || 1; + // Recreate the array to avoid altering the reference. + if (!newArray) { + property = property.slice(0); + property[0] = num; + } + } + else if (num !== '*' && + ('number' !== typeof num || !isFinite(num) || num < 1)) { + + throw new TypeError('SizeManager.init: ' + name + + 'Players must be a finite number greater ' + + 'than 1 or a wildcard (*,@). Found: ' + num); + } + + if (!cb) { + property[1] = null; + } + else if ('function' !== typeof cb) { + + throw new TypeError('SizeManager.init: ' + name + + 'Players cb must be ' + + 'function or undefined. Found: ' + cb); + } + + if (!recoverCb) { + property[2] = null; + } + else if ('function' !== typeof cb) { + + throw new TypeError('SizeManager.init: ' + name + + 'Players recoverCb must be ' + + 'function or undefined. Found: ' + recoverCb); + } + + return property; + } + + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Stager stages and steps + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + */ +(function(exports, node) { + + var J = node.JSUS; + + // Export Stager. + var Stager = exports.Stager = {}; + + /** + * ## Block.blockTypes + * + * List of available block types + */ + var blockTypes = { + + // #### BLOCK_DEFAULT + // + // The first block automatically added to the stager. + // + BLOCK_DEFAULT: '__default', + + // #### BLOCK_STAGEBLOCK + // + // A block that is a collection of stages + // + BLOCK_STAGEBLOCK: '__stageBlock_', + + // #### BLOCK_STAGE + // + // A block that contains a stage (possibly contains a step block) + // + BLOCK_STAGE: '__stage', + + // #### BLOCK_STEPBLOCK + // + // A block that is a collection of steps + // + BLOCK_STEPBLOCK: '__stepBlock_', + + // #### BLOCK_STEP + // + // A block that contains a step. + // + BLOCK_STEP: '__step', + + // BLOCK_ENCLOSING + // + // TODO: check . It is suffix using for search and to compose names + // + BLOCK_ENCLOSING: '__enclosing_', + + // #### BLOCK_ENCLOSING_STEPS + // + // + // + BLOCK_ENCLOSING_STEPS: '__enclosing_steps', + + // #### BLOCK_ENCLOSING_STAGES + // + // + // + BLOCK_ENCLOSING_STAGES: '__enclosing_stages', + }; + + // Add private functions to Stager. + Stager.blockTypes = blockTypes; + Stager.checkPositionsParameter = checkPositionsParameter; + Stager.addStageBlock = addStageBlock; + Stager.addBlock = addBlock; + Stager.checkFinalized = checkFinalized; + Stager.handleStepsArray = handleStepsArray; + Stager.makeDefaultCb = makeDefaultCb; + Stager.isDefaultCb = isDefaultCb; + Stager.isDefaultStep = isDefaultStep; + Stager.makeDefaultStep = makeDefaultStep; + Stager.unmakeDefaultStep = unmakeDefaultStep; + Stager.addStepToBlock = addStepToBlock; + + var BLOCK_DEFAULT = blockTypes.BLOCK_DEFAULT; + var BLOCK_STAGEBLOCK = blockTypes.BLOCK_STAGEBLOCK; + var BLOCK_STAGE = blockTypes.BLOCK_STAGE; + + /** + * #### handleStepsArray + * + * Validates the items of a steps array, creates new steps if necessary + * + * @param {Stager} that Stager object + * @param {string} stageId The original stage id + * @param {array} steps The array of steps to validate + * @param {string} method The name of the method invoking the method + */ + function handleStepsArray(that, stageId, steps, method) { + var i, len; + i = -1, len = steps.length; + // Missing steps are added with default callback (if string), + // or as they are, if object. + for ( ; ++i < len ; ) { + if ('object' === typeof steps[i]) { + // Throw error if step.id is not unique. + that.addStep(steps[i]); + // Substitute with its id. + steps[i] = steps[i].id; + } + else if ('string' === typeof steps[i]) { + if (!that.steps[steps[i]]) { + // Create a step with a default cb (will be substituted). + // Note: default callback and default step are two + // different things. + that.addStep({ + id: steps[i], + cb: that.getDefaultCb() + }); + } + } + else { + throw new TypeError('Stager.' + method + ': stage ' + + stageId + ': each item in the steps ' + + ' array must be string or object. Found: ' + + steps[i]); + } + } + } + + + /** + * #### addStageBlock + * + * Close last step and stage blocks and add a new stage block + * + * @param {Stager} that The stager instance + * @param {string} id Optional. The id of the stage block + * @param {string} type The type of the stage block: + * - BLOCK_ENCLOSING_STAGES + * - BLOCK_STAGEBLOCK + * @param {string|number} The allowed positions for the block + * + * @see addBlock + */ + function addStageBlock(that, id, type, positions) { + var toClose; + // When the default block is added, this does not apply yet. + // It is **not** executed only for the first user block. + if (that.currentStage !== BLOCK_DEFAULT + + // Adding a stage after a stage block. + // && that.currentBlockType !== BLOCK_STAGEBLOCK + ) { + + // TODO: check why if type is BLOCK_STAGEBLOCK it wants only one + // (or zero?) + // block closed. It works, but it is unclear why. In this way, + // it closes the steps from the previous block and leaves open + // the stage (and stage-block). + toClose = type === BLOCK_STAGEBLOCK ? 1 : 2; + that.endBlocks(toClose + that.openStepBlocks); + that.openStepBlocks = 0; + } + // that.currentStage = BLOCK_DEFAULT; // TODO: do we need this line? + addBlock(that, id, type, positions, BLOCK_STAGE); + } + + /** + * #### addBlock + * + * Adds a new block of the specified type to the sequence + * + * @param {Stager} that The stager instance + * @param {string} id Optional. The id of the stage block + * @param {string} type The block type + * @param {string|number} positions The allowed positions for the block + * @param {string} currentBlockType The value for `Stager.currentBlockType` + * (BLOCK_STAGE or BLOCK_STEP) + */ + function addBlock(that, id, type, positions, currentBlockType) { + var block; + + // Set current block type. + that.currentBlockType = currentBlockType; + + // Create the new block, and add it block arrays. + + block = new node.Block({ + id: id || J.uniqueKey(that.blocksIds, type), + type: type, + positions: positions + }); + that.unfinishedBlocks.push(block); + that.blocks.push(block); + + // Save block id into the blocks map. + that.blocksIds[block.id] = (that.blocks.length - 1); + } + + /** + * #### checkFinalized + * + * Check whether the stager is already finalized, and throws an error if so + * + * @param {object} that Reference to Stager object + * @param {string} method The name of the method calling the validation + * + * @api private + */ + function checkFinalized(that, method) { + if (that.finalized) { + throw new Error('Stager.' + method + ': stager has been ' + + 'already finalized'); + } + } + + /** + * #### checkPositionsParameter + * + * Check validity of a positions parameter + * + * Called by: `stage`, `repeat`, `doLoop`, 'loop`. + * + * @param {string|number} stage The positions parameter to validate + * @param {string} method The name of the method calling the validation + * + * @api private + */ + function checkPositionsParameter(positions, method) { + var err; + if ('undefined' === typeof positions) return; + if ('number' === typeof positions) { + if (isNaN(positions) || + positions < 0 || + !isFinite(positions)) { + err = true; + } + else { + positions += ''; + } + } + + if (err || 'string' !== typeof positions || positions.trim() === '') { + throw new TypeError('Stager.' + method + ': positions must ' + + 'be a non-empty string, a positive finite ' + + 'number, or undefined. Found: ' + positions); + } + return positions; + } + + /** + * #### addStepToBlock + * + * Adds a step to a block + * + * Checks if a step with the same id was already added. + * + * @param {object} that Reference to Stager object + * @param {object} stage The block object + * @param {string} stepId The id of the step + * @param {string} stageId The id of the stage the step belongs to + * @param {string|number} positions Optional. Positions allowed for + * step in the block + * + * @return {boolean} TRUE if the step is added to the block + */ + function addStepToBlock(that, block, stepId, stageId, positions) { + var stepInBlock; + + // Add step, if not already added. + if (block.hasItem(stepId)) return false; + + stepInBlock = { + type: stageId, + item: stepId, + id: stepId + }; + + if (isDefaultStep(that.steps[stepId])) { + makeDefaultStep(stepInBlock); + } + block.add(stepInBlock, positions); + return true; + } + + /** + * #### makeDefaultCb + * + * Flags or create a callback function marked as `default` + * + * @param {function} cb Optional. The function to mark. If undefined, + * an empty function is used + * + * @return {function} A function flagged as `default` + * + * @see isDefaultCb + */ + function makeDefaultCb(cb) { + if ('undefined' === typeof cb) cb = function() {}; + cb._defaultCb = true; + return cb; + } + + /** + * #### isDefaultCb + * + * Returns TRUE if a callback was previously marked as `default` + * + * @param {function} cb The function to check + * + * @return {boolean} TRUE if function is default callback + * + * @see makeDefaultCb + */ + function isDefaultCb(cb) { + return cb._defaultCb; + } + + /** + * #### makeDefaultStep + * + * Flags or create a step object marked as `default` + * + * @param {object|string} step The step object to mark. If a string + * is passed, a new step object with default cb is created. + * @ param {function} cb Optional A function to create the step cb + * + * @return {object} step the step flagged as `default` + * + * @see makeDefaultCb + * @see isDefaultStep + */ + function makeDefaultStep(step, cb) { + if ('string' === typeof step) { + step = { + id: step, + cb: makeDefaultCb(cb) + }; + } + step._defaultStep = true; + return step; + } + + /** + * #### unmakeDefaultStep + * + * Removes the flag from a step marked as `default` + * + * @param {object} step The step object to unmark. + * + * @return {object} step the step without the `default` flag + * + * @see makeDefaultDefaultStep + * @see isDefaultStep + */ + function unmakeDefaultStep(step) { + if (step._defaultStep) step._defaultStep = null; + return step; + } + + /** + * #### isDefaultStep + * + * Returns TRUE if a step object was previously marked as `default` + * + * @param {object} step The step object to check + * + * @return {boolean} TRUE if step object is default step + * + * @see makeDefaultStep + */ + function isDefaultStep(step) { + return step._defaultStep; + } + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Block + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * Blocks contain items that can be sorted in the sequence. + * + * Blocks can also contain other blocks as items, in this case all + * items are sorted recursevely. + * + * Each item must contain an id (unique within the block), and a type parameter. + * Optionally, a `positions` parameter, controlling the positions that the item + * can take in the sequence, can be be passed along. + * + * Items is encapsulated in objects of the type: + * + * ```js + * { item: item, positions: positions } + * ``` + * and added to the `unfinishedItems` array. + * + * When the finalized method is called, items are sorted according to the + * `positions` parameter and moved into the items array. + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + exports.Block = Block; + + var J = parent.JSUS; + + // Mock stager object. Contains only shared variables at this point. + // The stager class will be added later. + var Stager = parent.Stager; + + // Referencing shared entities. + var isDefaultStep = Stager.isDefaultStep; + var blockTypes = Stager.blockTypes; + var BLOCK_ENCLOSING_STEPS = blockTypes.BLOCK_ENCLOSING_STEPS; + + + /** + * ## Block constructor + * + * Creates a new instance of Block + * + * @param {object} options Configuration object + */ + function Block(options) { + if ('object' !== typeof options) { + throw new TypeError('Block constructor: options must be object: ' + + options); + } + + if ('string' !== typeof options.type || options.type.trim() === '') { + throw new TypeError('Block constructor: options.type must ' + + 'be a non-empty string: ' + options.type); + } + + if ('string' !== typeof options.id || options.id.trim() === '') { + throw new TypeError('Block constructor: options.id must ' + + 'be a non-empty string: ' + options.id); + } + + // ### Properties + + /** + * #### Block.type + * + * Stage or Step block + */ + this.type = options.type; + + /** + * #### Block.id + * + * An identifier (name) for the block instance + */ + this.id = options.id; + + /** + * #### Block.positions + * + * Positions in the enclosing Block that this block can occupy + */ + this.positions = 'undefined' !== typeof options.positions ? + options.positions : 'linear'; + + /** + * #### Block.takenPositions + * + * Positions within this Block that this are occupied + */ + this.takenPositions = []; + + /** + * #### Block.items + * + * The sequence of items within this Block + */ + this.items = []; + + /** + * #### Block.itemsIds + * + * List of the items added to the block so far + */ + this.itemsIds = {}; + + /** + * #### Block.unfinishedItems + * + * Items that have not been assigned a position in this block + */ + this.unfinishedItems = []; + + /** + * #### Block.index + * + * Index of the current element to be returned by Block.next + * + * @see Block.next + */ + this.index = 0; + + /** + * #### Block.finalized + * + * Flag to indicate whether a block is completed + */ + this.finalized = false; + + /** + * #### Block.resetCache + * + * Cache object to reset Block after finalization + */ + this.resetCache = null; + + } + + // ### Methods + + /** + * #### Block.add + * + * Adds an item to a block + * + * @param {object} item The item to be added + * @param {string} positions The positions where item can be added + * Setting this parameter to "linear" or undefined adds the + * item to the next free n-th position where this is the n-th + * call to add. + */ + Block.prototype.add = function(item, positions) { + + if (this.finalized) { + throw new Error('Block.add: block already finalized, ' + + 'cannot add further items'); + } + + if ('string' !== typeof item.id) { + throw new TypeError('Block.add: block ' + this.id + ': item id ' + + 'must be string: ' + item.id || 'undefined'); + } + if ('string' !== typeof item.type) { + throw new TypeError('Block.add: block ' + this.id + + ': item type must be string: ' + + item.type || 'undefined'); + } + + if (this.itemsIds[item.id]) { + throw new TypeError('Block.add: block ' + this.id + + ': item was already added to block: ' + + item.id); + } + + + // We cannot set the position as a number here, + // because it might change with future modifications of + // the block. Only on block.finalize the position is fixed. + if ('undefined' === typeof positions) { + positions = 'linear'; + } + + this.unfinishedItems.push({ + item: item, + positions: positions + }); + + // Save item's id. + this.itemsIds[item.id] = true; + }; + + /** + * #### Block.remove + * + * Removes an item from a block + * + * @param {string} itemId The id of the item to be removed + * + * @return {object} The removed item, or undefined if the item + * does not exist + */ + Block.prototype.remove = function(itemId) { + var i, len; + + if (this.finalized) { + throw new Error('Block.remove: block already finalized, ' + + 'cannot remove items.'); + } + + if (!this.hasItem(itemId)) return; + + i = -1, len = this.unfinishedItems.length; + for ( ; ++i < len ; ) { + if (this.unfinishedItems[i].item.id === itemId) { + this.itemsIds[itemId] = null; + + // Delete from cache as well. + if (this.resetCache && + this.resetCache.unfinishedItems[itemId]) { + + delete this.resetCache.unfinishedItems[itemId]; + } + return this.unfinishedItems.splice(i,1); + } + } + + throw new Error('Block.remove: item ' + itemId + ' was found in the ' + + 'in the itemsIds list, but could not be removed ' + + 'from block ' + this.id); + }; + + /** + * #### Block.removeAllItems + * + * Removes all items from a block + * + * @see Block.remove + */ + Block.prototype.removeAllItems = function() { + var i, len; + + if (this.finalized) { + throw new Error('Block.remove: block already finalized, ' + + 'cannot remove items.'); + } + + i = -1, len = this.unfinishedItems.length; + for ( ; ++i < len ; ) { + // Always remove item 0, size is changing. + this.remove(this.unfinishedItems[0].item.id); + } + + }; + + /** + * #### Block.hasItem + * + * Checks if an item has been previously added to block + * + * @param {string} itemId The id of item to check + * + * @return {boolean} TRUE, if the item is found + */ + Block.prototype.hasItem = function(itemId) { + return !!this.itemsIds[itemId]; + }; + + /** + * #### Block.finalize + * + * Processes all unfinished entries, assigns each to a position + * + * Sets the finalized flag. + */ + Block.prototype.finalize = function() { + var entry, item, positions, i, len, chosenPosition; + var available; + + if (this.finalized) return; + if (!this.unfinishedItems.length) { + this.finalized = true; + return; + } + + // Remove default step if it is BLOCK_STEP and further steps were added. + if (this.isType(BLOCK_ENCLOSING_STEPS) && this.size() > 1) { + if (isDefaultStep(this.unfinishedItems[0].item)) { + // Remove the id of the removed item from the lists of ids. + this.itemsIds[this.unfinishedItems[0].item.id] = null; + this.unfinishedItems.splice(0,1); + } + } + + i = -1, len = this.unfinishedItems.length; + // Update the positions of other steps as needed. + for ( ; ++i < len ; ) { + if (this.unfinishedItems[i].positions === 'linear') { + this.unfinishedItems[i].positions = i; + } + } + + // Creating array of available positions: + // from 0 to nItems accounting for already taken positions. + available = J.seq(0, this.size()-1); + + // TODO: this could be done inside the while loop. However, as + // every iterations also other entries are updated, it requires + // multiple calls to J.range. + // Parsing all of the position strings into arrays. + i = -1, len = this.unfinishedItems.length; + for ( ; ++i < len ; ) { + positions = this.unfinishedItems[i].positions; + this.unfinishedItems[i].positions = J.range(positions, available); + } + + + // Assigning positions. + while (this.unfinishedItems.length > 0) { + // Select entry with least possibilities of where to go. + this.unfinishedItems.sort(sortFunction); + entry = this.unfinishedItems.pop(); + item = entry.item; + positions = entry.positions; + + // No valid position specified. + if (positions.length === 0) { + throw new Error('Block.finalize: no valid position for ' + + 'entry ' + item.id + ' in Block ' + this.id); + } + + // Chose position randomly among possibilities. + chosenPosition = positions[J.randomInt(0, positions.length) - 1]; + this.items[chosenPosition] = item; + this.takenPositions.push(chosenPosition); + + // Adjust possible positions in remaining entries. + i = -1, len = this.unfinishedItems.length; + for ( ; ++i < len ; ) { + J.removeElement(chosenPosition, + this.unfinishedItems[i].positions); + } + } + this.finalized = true; + }; + + /** + * #### Block.next + * + * Gets the next item in a hierarchy of Blocks + * + * If there is not next item, false is returned. + * If the next item is another Block, next is called recursively. + * + * @return {object|boolean} The the item in hierarchy, or FALSE + * if none is found. + */ + Block.prototype.next = function() { + var item; + if (this.index < this.items.length) { + item = this.items[this.index]; + if (item instanceof Block) { + item = item.next(); + if (item === false) { + this.index++; + return this.next(); + } + else { + return item; + } + } + else { + this.index++; + return item; + } + } + return false; + }; + + /** + * #### Block.backup + * + * Saves the current state of the block + * + * @see Block.restore + */ + Block.prototype.backup = function() { + this.resetCache = J.classClone({ + takenPositions: this.takenPositions, + unfinishedItems: this.unfinishedItems, + items: this.items, + itemsIds: this.itemsIds + }, 3); + }; + + /** + * #### Block.restore + * + * Resets the state of the block to the latest saved state + * + * Even if the reset cache for the block is empty, it sets + * index to 0 and finalized to false. + * + * Marks the block as not `finalized` + * + * @see Block.finalize + */ + Block.prototype.restore = function() { + this.index = 0; + this.finalized = false; + + if (!this.resetCache) return; + this.unfinishedItems = this.resetCache.unfinishedItems; + this.takenPositions = this.resetCache.takenPositions; + this.items = this.resetCache.items; + this.itemsIds = this.resetCache.itemsIds; + this.resetCache = null; + }; + + /** + * ## Block.size + * + * Returns the total number of items inside the block + * + * @return {number} The total number of items in the block + */ + Block.prototype.size = function() { + return this.items.length + this.unfinishedItems.length; + }; + + /** + * ## Block.isType | isOfType + * + * Returns TRUE if the block is of the specified type + * + * @param {string} type The type to check + * + * @return {boolean} TRUE if the block is of the specified type + */ + Block.prototype.isType = Block.prototype.isOfType = function(type) { + return this.type === type; + }; + + /** + * ## Block.clone + * + * Returns a copy of the block + * + * @return {Block} A new instance of block with the same settings and items + */ + Block.prototype.clone = function() { + var block; + block = new Block({ + type: this.type, + id: this.id + }); + + block.positions = J.clone(this.positions); + block.takenPositions = J.clone(this.takenPositions); + block.items = J.clone(this.items); + block.itemsIds = this.itemsIds; + block.unfinishedItems = J.clone(this.unfinishedItems); + block.index = this.index; + block.finalized = this.finalized; + block.resetCache = J.clone(this.resetCache); + return block; + }; + + // ## Helper Functions + + /** + * #### sortFunction + * + * Sorts elements in block by number of available positions + * + * Those with fewer positions go last, because then Array.pop is used. + * + * @api private + */ + function sortFunction(left, right) { + if (left.positions.length <= right.positions.length) return 1; + return -1; + } + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Stager + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * Builds and store the game sequence. + * + * The game sequence is a sequence of blocks which can be moved around + * until they are finalized. + * + * Blocks are generic containers and can contain steps, stages, and + * even other blocks. + * + * ## Stager Technical Guide + * + * ### The Default block. + * + * The sequence of blocks always begins with the default block: + * + * ```js + * { + * type: '__stageBlock_', + * id: '__default', + * positions: 'linear', + * takenPositions: Array(0), + * items: Array(0), + * itemsIds: {}, + * unfinishedItems: [], + * index: 0, + * finalized: false, + * resetCache: null + * } + * ``` + * + * which will contain all the other blocks. This block is added to both: + * + * - `Stager.blocks`, and + * - `Stager.unfinishedBlocks`. + * + * + * We test now this sequence and how it affects the stager internals. + * + * ```js + * stager.stage('myStage') + * stager.step('myStep') + * stager.stage('anotherStage') + * stager.stageBlock('myStageBlock', 'linear') + * stager.stage('stageInBlock') + * stager.stage('stageInBlock2') + * stager.stepBlock('myStepBlock', 'linear') + * stager.step('stepInBlock') + * stager.step('stepInBlock2') + * stager.stepBlock('anotherStepBlock', 'linear') + * stager.step('anotherStepInBlock') + * stager.step('anotherStepInBlock2') + * stager.stage('stageInBlock3') + * stager.stageBlock('anotherStageBlock', 'linear') + * stager.stage('lastStageInBlock') + * ``` + * + * ### Adding a first stage + * + * If we add a stage with + * + * ```js + * stager.stage('myStage'); + * ``` + * + * two blocks are added: + * + * ```js + * { + * type: '__enclosing_stages', + * id: '__enclosing_myStage_1', + * positions: 'linear', + * ... + * }, + * { type: '__enclosing_steps', + * id: '__enclosing_myStage_steps_1', + * positions: 'linear', + * ... + * } + * ``` + * + * to both `blocks` and `unfinishedBlocks`. + * + * ### Adding a first step inside the stage + * + * If we add a step with + * + * ```js + * stager.step('myStep'); + * ``` + * + * no new blocks are added, but one item is added inside the `unfinishedItems` + * array of the last open block ('__enclosing_myStage_steps_1'). + * + * Note! When the user adds the first step to a stage, it is actually the second + * item in the `unfinishedItems` array. The first one the default step named + * after the name of the stage. This default item will be removed upon + * finalizing the stage (if there are other steps in the stage) + * + * Any other step added inside the stage will add an item in the + * `unfinishedItems` array. + * + * + * ### Adding a second stage + * + * If we add another stage with + * + * ```js + * stager.stage('anotherStage'); + * ``` + * + * two blocks are added to the `blocks` array: + * + * ```js + * { + * type: '__enclosing_stages', + * id: '__enclosing_anotherStage_2', + * positions: 'linear', + * ... + * }, + * { + * type: '__enclosing_steps', + * id: '__enclosing_anotherStage_steps_2', + * positions: 'linear', + * ... + * } + * ``` + * + * The last two blocks in the `unfinishedBlocks` array removed, and two new + * ones for the new stage are added. + * + * + * ### Adding a first stage block + * + * If we add a stage block with: + * + * ```js + * stager.stageBlock('myStageBlock', 'linear'); + * ``` + * + * one block is added to the `blocks` array: + * + * ```js + * { + * type: '__stageBlock_', + * id: 'First StageBlock', + * positions: 'linear', + * ... + * } + * ``` + * + * The last block in the `unfinishedBlocks` array is removed (for the steps + * of the previous stage), and a new one for the new stage block is added + * No enclosing stages and steps blocks added yet. + * + * + * ### Adding one stage within a stage block + * + * If we add a stage in the block with: + * + * ```js + * stager.stage('stageInBlock'); + * ``` + * + * two blocks are added to the `blocks` array: + * + * ```js + { + type: '__enclosing_stages', + id: '__enclosing_stageInBlock_3', + positions: 'linear', + ... + }, + { + type: '__enclosing_steps', + id: '__enclosing_stageInBlock_steps_3', + positions: 'linear', + ... + } + * ``` + * + * to both `blocks` and `unfinishedBlocks`. The two last blocks from + * `unfinishedBlocks` are removed before (the stage-block enclosing this stage + * and the stage from previous block). ??? TODO CHECK whether this makes sense. + * + * Adding another stage within the stage block will behave the same way, + * will remove the current last two blocks from the `unfinishedBlocks` array + * and replaced with two blocks for the new stage. + * + * + * ### Adding a step block inside a stage + * + * If we add a step block inside a stage with: + * + * ```js + * stager.stepBlock('myStepBlock', 'linear'); + * ``` + * + * Adds a new block: + * + * ```js + * { + * type: '__stepBlock_', + * id: 'myStepBlock', + * positions: 'linear', + * ... + * } + * ``` + * + * to both `blocks` and `unfinishedBlocks`. No block is removed from + * the `unfinishedBlocks` array. + * + * + * ### Adding a step inside the step block + * + * If we add a step inside the step block with: + * + * ```js + * stager.step('stepInBlock'); + * ``` + * + * no new blocks are added, but one item is added inside the `unfinishedItems` + * array of the last open block ('myStepBlock'). + * + * ```js + * { + * type: 'stageInBlock2', + * item: 'stepInBlock', + * id: 'stepInBlock' + * } + * ``` + * + * Further steps in the same step block will add new items here. + * + * + * ### Adding another step block inside a stage + * + * If we add an additional step block inside a stage with: + * + * ```js + * stager.stepBlock('anotherStepBlock', 'linear'); + * ``` + * + * Adds a new block: + * + * ```js + * { + * type: '__stepBlock_', + * id: 'anotherStepBlock', + * positions: 'linear', + * ... + * } + * ``` + * + * to both `blocks` and `unfinishedBlocks`. No block is removed from + * the `unfinishedBlocks` array. + * + * ### Adding another stage after a step block + * + * If we add another stage with + * + * ```js + * stager.stage('stageInBlock3'); + * ``` + * + * two blocks are added to the `blocks` array: + * + * ```js + * { + * type: '__enclosing_stages', + * id: '__enclosing_stageInBlock3_6', + * positions: 'linear', + * ... + * }, + * { + * type: '__enclosing_steps', + * id: '__enclosing_stageInBlock3_steps_6', + * positions: 'linear', + * ... + * } + * ``` + * + * The last two blocks in the `unfinishedBlocks` array plus all step blocks + * (in total 4 blocks) are removed, and two new ones for the new + * stage are added. + * + * + * ### Adding a second stage block + * + * If we add a second stage block with: + * + * ```js + * stager.stageBlock('anotherStageBlock', 'linear'); + * ``` + * + * one block is added to the `blocks` array: + * + * ```js + * { + * type: '__stageBlock_', + * id: 'anotherStageBlock', + * positions: 'linear', + * ... + * } + * ``` + * + * The last block in the `unfinishedBlocks` array is removed (for the steps + * of the previous stage), and a new one for the new stage block is added + * No enclosing stages and steps blocks added yet. + * + * + * ### Adding one stage within the second stage block + * + * If we add a stage in the block with: + * + * ```js + * stager.stage('stageInBlock'); + * ``` + * + * two blocks are added to the `blocks` array: + * + * ```js + { + type: '__enclosing_stages', + id: '__enclosing_stageInBlock_3', + positions: 'linear', + ... + }, + { + type: '__enclosing_steps', + id: '__enclosing_stageInBlock_steps_3', + positions: 'linear', + ... + } + * ``` + * + * to both `blocks` and `unfinishedBlocks`. The two last blocks from + * `unfinishedBlocks` are removed before (the stage-block enclosing this stage + * and the stage from previous block). ??? TODO CHECK whether this makes sense. + * + * Adding another stage within the stage block will behave the same way, + * will remove the current last two blocks from the `unfinishedBlocks` array + * and replaced with two blocks for the new stage. + * + * + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + + var J = parent.JSUS; + + // What is in the Stager obj at this point. + var tmpStager = parent.Stager + // Add it to the Stager class. + J.mixin(Stager, tmpStager); + // Export the Stager class. + exports.Stager = Stager; + + // Referencing shared entities. + var blockTypes = Stager.blockTypes; + var isDefaultStep = Stager.isDefaultStep; + + // ## Static Methods + + /** + * #### Stager.defaultCallback + * + * Default callback added to steps when none is specified + * + * @see Stager.setDefaultCallback + * @see Stager.getDefaultCallback + */ + Stager.defaultCallback = function() { + this.node.log(this.getCurrentStepObj().id); + }; + + // Flag it as `default`. + Stager.makeDefaultCb(Stager.defaultCallback); + + /** + * ## Stager constructor + * + * Creates a new instance of Stager + * + * @param {object} stateObj Optional. State to initialize the new + * Stager object. + * + * @see Stager.setState + */ + function Stager(stateObj) { + + // ## Properties + + /** + * #### Stager.sequence + * + * Sequence block container + * + * Stores the game plan in 'simple mode'. + * + * @see Stager.gameover + * @see Stager.next + * @see Stager.repeat + * @see Stager.loop + * @see Stager.doLoop + */ + this.sequence = []; + + /** + * #### Stager.stages + * + * Maps stage ids to stage objects + * + * Each stage object contains an array of steps referencing an object + * in `Stager.steps`. + * + * Format: + * + * ```js + * { + * oneStage: { id: 'oneStage', steps: [ { ... } }, + * anotherStage: { id: 'anotherStage', steps: [ { ... } ] } + * } + * ``` + * + * Stage aliases are stored the same way, with a reference to + * the original stage object as the value. + * + * @see Stager.steps + * @see Stager.addStage + */ + this.stages = {}; + + /** + * #### Stager.steps + * + * Maps step ids to step objects + * + * Format: + * + * ```js + * { + * oneStep: { id: 'oneStep', cb: function() { ... } }, + * anotherStep: { id: 'anotherStep', cb: function() { ... } } + * } + * ``` + * + * @see Stager.addStep + */ + this.steps = {}; + + /** + * #### Stager.blocks + * + * Array of blocks in the order they were added to the stager + */ + this.blocks = []; + + /** + * #### Stager.blocksIds + * + * Map block-id to block-position in the blocks array + * + * @see blocks + */ + this.blocksIds = {}; + + /** + * #### Stager.unfinishedBlocks + * + * List of all Blocks stager might still modify + */ + this.unfinishedBlocks = []; + + /** + * #### Stager.openStepBlocks + * + * Number of step blocks that need to be closed when the stage is closed + * + * @see addStageBlock + */ + this.openStepBlocks = 0; + + /** + * #### Stager.currentStage + * + * Name of the current stage in the blocks' hierarchy + */ + this.currentStage = blockTypes.BLOCK_DEFAULT; + + /** + * #### Stager.currentBlockType + * + * The type of block tharwas added last + * + * @see blockTypes + */ + this.currentBlockType = blockTypes.BLOCK_DEFAULT; + + /** + * #### Stager.generalNextFunction + * + * General next-stage decider function + * + * Returns the id of the next game step. + * Available only when nodegame is executed in _flexible_ mode. + * + * @see Stager.registerGeneralNext + */ + this.generalNextFunction = null; + + /** + * #### Stager.nextFunctions + * + * Per-stage next-stage decider function + * + * key: stage ID, value: callback function + * + * Stores functions to be called to yield the id of the next + * game stage for a specific previous stage. + * + * @see Stager.registerNext + */ + this.nextFunctions = {}; + + /** + * #### Stager.defaultStepRule + * + * Default step-rule function + * + * This function decides whether it is possible to proceed to + * the next step/stage. If a step/stage object defines a + * `steprule` property, then that function is used instead. + * + * @see Stager.getDefaultStepRule + * @see GamePlot.getStepRule + */ + this.setDefaultStepRule(); + + /** + * #### Stager.defaultGlobals + * + * Defaults of global variables + * + * This map holds the default values of global variables. These + * values are overridable by more specific version in step and + * stage objects. + * + * @see Stager.setDefaultGlobals + * @see GamePlot.getGlobal + */ + this.defaultGlobals = {}; + + /** + * #### Stager.defaultProperties + * + * Defaults of properties + * + * This map holds the default values of properties. These values + * are overridable by more specific version in step and stage + * objects. + * + * @see Stager.setDefaultProperties + * @see GamePlot.getProperty + */ + this.defaultProperties = {}; + + /** + * #### Stager.onInit + * + * Initialization function + * + * This function is called as soon as the game is instantiated, + * i.e. at stage 0.0.0. + * + * Event listeners defined here stay valid throughout the whole + * game, unlike event listeners defined inside a function of the + * gamePlot, which are valid only within the specific function. + */ + this.onInit = null; + + /** + * #### Stager.onGameover + * + * Cleaning up function + * + * This function is called after the last stage of the gamePlot + * is terminated. + */ + this.onGameover = null; + + + /** + * #### Stager.finalized + * + * Flag indicating if the hierarchy of has been set + * + * Indicates if the hierarchy of stages and steps has been set. + */ + this.finalized = false; + + + + /** + * #### Stager.toSkip + * + * List of stages and steps to skip when building the sequence + * + * Skipped steps are stored as "stageId.stepId". + * + * If a stage/step is unskipped, its entry is set to null. + * + * @see Stager.skip + * @see Stager.unskip + */ + this.toSkip = { + stages: {}, + steps: {} + }; + + /** + * #### Stager.defaultCallback + * + * Default callback assigned to a step if none is provided + */ + this.defaultCallback = Stager.defaultCallback; + + /** + * #### Stager.cacheReset + * + * Cache used to reset the state of the stager after finalization + */ + this.cacheReset = { + unfinishedBlocks: [] + }; + + /** + * #### Stager.log + * + * Default standard output. Override to redirect. + */ + this.log = console.log; + + // Set the state if one is passed. + if (stateObj) { + if ('object' !== typeof stateObj) { + throw new TypeError('Stager: stateObj must be object. ' + + 'Found: ' + stateObj); + } + this.setState(stateObj); + } + else { + // Add first block. + this.stageBlock(blockTypes.BLOCK_DEFAULT, 'linear'); + } + } + + // ## Methods + + // ### Clear, init, finalize, reset. + + /** + * #### Stager.clear + * + * Clears the state of the stager + * + * @return {Stager} this object + */ + Stager.prototype.clear = function() { + this.steps = {}; + this.stages = {}; + this.sequence = []; + this.openStepBlocks = 0; + this.generalNextFunction = null; + this.nextFunctions = {}; + this.setDefaultStepRule(); + this.defaultGlobals = {}; + this.defaultProperties = {}; + this.onInit = null; + this.onGameover = null; + this.blocks = []; + this.blocksIds = {}; + this.unfinishedBlocks = []; + this.finalized = false; + this.currentStage = blockTypes.BLOCK_DEFAULT; + this.currentBlockType = blockTypes.BLOCK_DEFAULT; + this.toSkip = { stages: {}, steps: {} }; + this.defaultCallback = Stager.defaultCallback; + this.cacheReset = { unfinishedBlocks: [] }; + return this; + }; + + /** + * #### Stager.init + * + * Clears the state of the stager and adds a default block + * + * @return {Stager} this object + * + * @see Stager.clear + */ + Stager.prototype.init = function() { + this.clear(); + this.stageBlock(blockTypes.BLOCK_DEFAULT, 'linear'); + return this; + }; + + /** + * #### Stager.finalize + * + * Builds stage and step sequence from the blocks' hieararchy + * + * Stages and steps are excluded from the sequence if they were marked + * as _toSkip_. + * + * Steps are excluded from the sequence if they were added as + * _default step_, but then other steps have been added to the same stage. + * + * @see Stager.reset + */ + Stager.prototype.finalize = function() { + var currentItem, stageId, stepId; + var outermostBlock, blockIndex; + var i, len, seqItem; + + // Already finalized. + if (this.finalized) return; + + // Nothing to do, finalize called too early. + if (!this.blocks.length) return; + + // Cache the ids of unfinishedBlocks for future calls to .reset. + i = -1, len = this.unfinishedBlocks.length; + for ( ; ++i < len ; ) { + this.cacheReset.unfinishedBlocks.push(this.unfinishedBlocks[i].id); + } + + // Need to backup all blocks before calling endAllBlocks(). + for (blockIndex = 0; blockIndex < this.blocks.length; ++blockIndex) { + this.blocks[blockIndex].backup(); + } + + + // Closes unclosed blocks. + this.endAllBlocks(); + + // Fixes the position of unfixed elements inside each block. + for (blockIndex = 0; blockIndex < this.blocks.length; ++blockIndex) { + this.blocks[blockIndex].finalize(); + } + + // Take outermost block and start building sequence. + outermostBlock = this.blocks[0]; + currentItem = outermostBlock.next(); + while (currentItem) { + if (currentItem.type === blockTypes.BLOCK_STAGE) { + stageId = currentItem.item.id; + // Add it to sequence if it was + // not marked as `toSkip`, or it is a gameover stage. + if (currentItem.item.type === 'gameover' || + !this.isSkipped(stageId)) { + + seqItem = J.clone(currentItem.item); + seqItem.steps = []; + this.sequence.push(seqItem); + } + } + else { + // It is a step, currentItem.type = stage id (TODO: change). + stageId = currentItem.type; + stepId = currentItem.item; + + // 1 - Step was marked as `toSkip`. + if (!this.isSkipped(stageId, stepId) && + + // 2 - Step was a default step, + // but other steps have been added. + (!isDefaultStep(this.steps[stepId]) || + this.stages[stageId].steps.length === 1)) { + + // Ok, add the step to the sequence (must look up stage). + i = -1, len = this.sequence.length; + for ( ; ++i < len ; ) { + if (this.sequence[i].id === stageId) { + this.sequence[i].steps.push(stepId); + break; + } + } + } + } + currentItem = outermostBlock.next(); + } + this.finalized = true; + }; + + /** + * #### Stager.reset + * + * Undoes a previous call to `finalize` + * + * Allows to call `Stager.finalize` again to build a potentially + * different sequence from the Block hierarchy. + * + * @see Stager.finalize + * @see Stager.cacheReset + */ + Stager.prototype.reset = function() { + var blockIdx, i, len; + + if (!this.finalized) return this; + + // Restore unfinishedBlocks, if any. + len = this.cacheReset.unfinishedBlocks.length; + if (len) { + // Copy by reference cached blocks. + i = -1; + for ( ; ++i < len ; ) { + blockIdx = this.blocksIds[this.cacheReset.unfinishedBlocks[i]]; + this.unfinishedBlocks.push(this.blocks[blockIdx]); + } + this.cacheReset = { unfinishedBlocks: []}; + } + // End restore unfinishedBlocks. + + // Call restore on individual blocks. + for (blockIdx = 0; blockIdx < this.blocks.length; ++blockIdx) { + this.blocks[blockIdx].restore(); + } + + this.sequence = []; + this.finalized = false; + }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Stager stages and steps + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + */ +(function(exports, node) { + + var J = node.JSUS; + var Stager = node.Stager; + + // Get reference to shared entities in Stager. + var checkPositionsParameter = Stager.checkPositionsParameter; + var addStageBlock = Stager.addStageBlock; + var addBlock = Stager.addBlock; + var checkFinalized = Stager.checkFinalized; + var handleStepsArray = Stager.handleStepsArray; + var makeDefaultStep = Stager.makeDefaultStep; + var addStepToBlock = Stager.addStepToBlock; + + var blockTypes = Stager.blockTypes; + var BLOCK_STAGE = blockTypes.BLOCK_STAGE; + var BLOCK_STEPBLOCK = blockTypes.BLOCK_STEPBLOCK; + var BLOCK_STEP = blockTypes.BLOCK_STEP; + + var BLOCK_ENCLOSING = blockTypes.BLOCK_ENCLOSING; + var BLOCK_ENCLOSING_STEPS = blockTypes.BLOCK_ENCLOSING_STEPS; + var BLOCK_ENCLOSING_STAGES = blockTypes.BLOCK_ENCLOSING_STAGES; + + /** + * #### Stager.addStep | createStep + * + * Adds a new step + * + * Registers a new game step object. Must have the following fields: + * + * - id (string): The step's name + * - cb (function): The step's callback function + * + * @param {object} step A valid step object. Shallowly copied. + */ + Stager.prototype.createStep = Stager.prototype.addStep = function(step) { + checkStepValidity(step, 'addStep'); + + if (this.steps.hasOwnProperty(step.id)) { + throw new Error('Stager.addStep: step "' + step.id + '" already ' + + 'existing, use extendStep to modify it'); + } + this.steps[step.id] = step; + }; + + /** + * #### Stager.addStage | createStage + * + * Adds a new stage + * + * Registers a new game stage object. Must have an id field: + * + * - id (string): The stage's name + * + * and either of the two following fields: + * + * - steps (array of strings|objects): The names of the steps belonging + * to this stage, or the steps objects to define them. In the latter + * case steps with the same id must not have been defined before. + * + * - cb (function): The callback function. If this field is used, + * then a step with the same name as the stage will be created, + * containing all the properties. The stage will be an empty + * container referencing + * + * @param {object} stage A valid stage or step object. Shallowly + * copied. + * + * @see checkStageValidity + */ + Stager.prototype.createStage = Stager.prototype.addStage = function(stage) { + var id; + + checkStageValidity(stage, 'addStage'); + + id = stage.id; + + if (this.stages.hasOwnProperty(id)) { + throw new Error('Stager.addStage: stage "' + id + '" already ' + + 'existing, use extendStage to modify it'); + } + + // The stage contains only 1 step inside given through the callback + // function. A step will be created with same id and callback. + if (stage.cb) { + this.addStep({ + id: id, + cb: stage.cb + }); + delete stage.cb; + stage.steps = [ id ]; + } + else { + // Process every step in the array. Steps array is modified. + handleStepsArray(this, id, stage.steps, 'addStage'); + } + this.stages[id] = stage; + }; + + /** + * #### Stager.cloneStep + * + * Clones a stage and assigns a new id to it + * + * @param {string} stepId The name of the stage to clone + * @param {string} newStepId The new unique id to assign to the clone + * + * @return {object} step Reference to the cloned step + * + * @see Stager.addStep + */ + Stager.prototype.cloneStep = function(stepId, newStepId) { + var step; + if ('string' !== typeof stepId) { + throw new TypeError('Stager.cloneStep: stepId must be string. ' + + 'Found: ' + stepId); + } + if ('string' !== typeof newStepId) { + throw new TypeError('Stager.cloneStep: newStepId must be ' + + 'string. Found: ' + newStepId); + } + if (this.steps[newStepId]) { + throw new Error('Stager.cloneStep: newStepId already taken: ' + + newStepId); + } + step = this.steps[stepId]; + if (!step) { + throw new Error('Stager.cloneStep: step not found: ' + stepId); + } + step = J.clone(step); + step.id = newStepId; + this.addStep(step); + return step; + }; + + /** + * #### Stager.cloneStage + * + * Clones a stage and assigns a new id to it + * + * @param {string} stageId The id of the stage to clone + * @param {string} newStageId The new unique id to assign to the clone + * + * @return {object} stage Reference to the cloned stage + * + * @see Stager.addStage + */ + Stager.prototype.cloneStage = function(stageId, newStageId) { + var stage; + if ('string' !== typeof stageId) { + throw new TypeError('Stager.cloneStage: stageId must be string.' + + 'Found: ' + stageId); + } + if ('string' !== typeof newStageId) { + throw new TypeError('Stager.cloneStage: newStageId must ' + + 'be string. Found: ' + newStageId); + } + if (this.stages[newStageId]) { + throw new Error('Stager.cloneStage: newStageId already taken: ' + + newStageId + '.'); + } + stage = this.stages[stageId]; + if (!stage) { + throw new Error('Stager.cloneStage: stage not found: ' + stageId); + } + stage = J.clone(stage); + stage.id = newStageId; + this.addStage(stage); + return stage; + }; + + /** + * #### Stager.step + * + * Adds a step to the current Block. + * + * @param {string|object} stage A valid step object or the stepId string. + * @param {string} positions Optional. Positions within the + * enclosing Block that this step can occupy. + * + * @return {Stager} Reference to this instance for method chaining + * + * @see Stager.addStep + */ + Stager.prototype.step = function(step, positions) { + var id, curBlock; + + curBlock = this.getCurrentBlock(); + if (!curBlock.isType(BLOCK_ENCLOSING_STEPS) && + !curBlock.isType(BLOCK_STEPBLOCK)) { + + throw new Error('Stager.step: step "' + step + '" cannot be ' + + 'added here. Have you add at least one stage?'); + } + + checkFinalized(this, 'step'); + id = handleStepParameter(this, step, 'step'); + positions = checkPositionsParameter(positions, 'step'); + + addStepToBlock(this, curBlock, id, this.currentStage, positions); + + this.stages[this.currentStage].steps.push(id); + + return this; + }; + + /** + * #### Stager.next | stage + * + * Adds a stage block to sequence + * + * The `id` parameter must have the form 'stageID' or 'stageID AS alias'. + * stageID must be a valid stage and it (or alias if given) must be + * unique in the sequence. + * + * @param {string|object} id A stage name with optional alias + * or a stage object. + * @param {string} positions Optional. Allowed positions for the stage + * + * @return {Stager} Reference to this instance for method chaining + * + * @see Stager.addStage + */ + Stager.prototype.stage = Stager.prototype.next = + function(stage, positions) { + var stageName; + + checkFinalized(this, 'next'); + stageName = handleStageParameter(this, stage, 'next'); + positions = checkPositionsParameter(positions, 'next'); + + addStageToCurrentBlock(this, { + type: 'plain', + id: stageName + }, positions); + + // Must be done after addStageToCurrentBlock. + addStepsToCurrentBlock(this, this.stages[stageName].steps); + return this; + }; + + /** + * #### Stager.repeat | repeatStage + * + * Adds repeated stage block to sequence + * + * @param {string|object} stage A stage name with optional alias + * or a stage object. + * @param {string} positions Optional. Allowed positions for the stage + * + * @return {Stager} Reference to this instance for method chaining + * + * @see Stager.addStage + * @see Stager.next + */ + Stager.prototype.repeatStage = Stager.prototype.repeat = + function(stage, nRepeats, positions) { + var stageName; + + checkFinalized(this, 'repeat'); + + stageName = handleStageParameter(this, stage, 'next'); + + if ('number' !== typeof nRepeats || + isNaN(nRepeats) || + nRepeats <= 0) { + + throw new Error('Stager.repeat: nRepeats must be a positive ' + + 'number. Found: ' + nRepeats); + } + + positions = checkPositionsParameter(positions, 'repeat'); + + addStageToCurrentBlock(this, { + type: 'repeat', + id: stageName, + num: parseInt(nRepeats, 10) + }, positions); + + // Must be done after addStageToCurrentBlock is called. + addStepsToCurrentBlock(this, this.stages[stageName].steps); + return this; + }; + + /** + * #### Stager.loop | loopStage + * + * Adds looped stage block to sequence + * + * The given stage will be repeated as long as the `func` callback + * returns TRUE. If it returns FALSE on the first time, the stage is + * never executed. + * + * @param {string|object} stage A stage name with optional alias + * or a stage object. + * @param {function} loopFunc Callback returning TRUE for + * repetition. + * + * @return {Stager} Reference to this instance for method chaining + * + * @see Stager.addStage + * @see Stager.next + * @see Stager.doLoop + */ + Stager.prototype.loopStage = Stager.prototype.loop = + function(stage, loopFunc, positions) { + + return addLoop(this, 'loop', stage, loopFunc, positions); + }; + + /** + * #### Stager.doLoop | doLoopStage + * + * Adds alternatively looped stage block to sequence + * + * The given stage will be repeated once plus as many times as the + * `func` callback returns TRUE. + * + * @param {string|object} stage A stage name with optional alias + * or a stage object. + * @param {function} loopFunc Optional. Callback returning TRUE for + * repetition. + * + * @return {Stager} Reference to this instance for method chaining + * + * @see Stager.addStage + * @see Stager.next + * @see Stager.loop + */ + Stager.prototype.doLoopStage = Stager.prototype.doLoop = + function(stage, loopFunc, positions) { + + return addLoop(this, 'doLoop', stage, loopFunc, positions); + }; + + /** + * #### Stager.gameover + * + * Adds gameover block to sequence + * + * @return {Stager} this object + */ + Stager.prototype.gameover = function() { + addStageToCurrentBlock(this, { + id: 'gameover', + type: 'gameover' + }); + return this; + }; + + // ## Private Methods + + /** + * #### addLoop + * + * Handles adding a looped stage (doLoop or loop) + * + * @param {object} that Reference to Stager object + * @param {string} type The type of loop (doLoop or loop) + * @param {string|object} stage The stage to loop + * @param {function} loopFunc The function checking the + * @param {string} positions Optional. Positions within the + * enclosing Block that this block can occupy. + * + * @return {Stager|null} this object on success, NULL on error + * + * @see Stager.loop + * @see Stager.doLoop + * + * @api private + */ + function addLoop(that, type, stage, loopFunc, positions) { + var stageName; + + checkFinalized(that, type); + + stageName = handleStageParameter(that, stage, type); + + if ('function' !== typeof loopFunc) { + throw new TypeError('Stager.' + type + ': loopFunc must be ' + + 'function. Found: ' + loopFunc); + } + + positions = checkPositionsParameter(positions, type); + + addStageToCurrentBlock(that, { + type: type, + id: stageName, + cb: loopFunc + }, positions); + + // Must be done after addStageToCurrentBlock is called. + addStepsToCurrentBlock(that, that.stages[stageName].steps); + return that; + } + + /** + * #### addStageToCurrentBlock + * + * Performs several meta operations necessary to add a stage block + * + * Operations: + * + * - Ends any unclosed blocks. + * - Begin a new enclosing block. + * - Adds a stage block. + * - Adds a steps block. + * + * @param {Stager} that Stager object + * @param {object} stage The stage to add containing its type + * @param {string} positions Optional. The allowed positions for the stage + * + * @api private + */ + function addStageToCurrentBlock(that, stage, positions) { + var name, curBlock, rndName; + name = stage.id || stage.type; + + // was: + // rndName = '_' + J.randomInt(10000); + rndName = '_' + Math.floor((that.blocks.length + 1)/2); + + // Closes last step and stage blocks. + // Then adds a new enclosing-stages block. + addStageBlock(that, + BLOCK_ENCLOSING + name + rndName, + BLOCK_ENCLOSING_STAGES, + positions); + + // Gets the enclosing-stages block just added. + curBlock = that.getCurrentBlock(); + curBlock.add({ + type: BLOCK_STAGE, + item: stage, + id: stage.id + }); + + that.currentStage = name; + + addBlock(that, + BLOCK_ENCLOSING + name + '_steps' + rndName, + BLOCK_ENCLOSING_STEPS, + 'linear', + BLOCK_STEP); + } + + /** + * #### addStepsToCurrentBlock + * + * Adds steps to current block + * + * For each step inside stage.step, it checks whether the step was + * already added to current block, and if not, it adds it. + * + * @param {object} that Reference to Stager object + * @param {array} steps Array containing the id of the steps + * + * @see addStepToBlock + */ + function addStepsToCurrentBlock(that, steps) { + var curBlock, i, len; + curBlock = that.getCurrentBlock(); + i = -1, len = steps.length; + for ( ; ++i < len ; ) { + addStepToBlock(that, curBlock, steps[i], that.currentStage); + } + } + + /** + * #### extractAlias + * + * Returns an object where alias and id are separated + * + * @param {string} nameAndAlias The stage-name string + * + * @return {object} Object with properties id and alias (if found) + * + * @api private + * + * @see handleAlias + */ + function extractAlias(nameAndAlias) { + var tokens; + tokens = nameAndAlias.split(' AS '); + return { + id: tokens[0].trim(), + alias: tokens[1] ? tokens[1].trim() : undefined + }; + } + + /** + * #### handleAlias + * + * Handles stage id and alias strings + * + * Takes a string like 'stageID' or 'stageID AS alias' and return 'alias'. + * Checks that alias and stage id are different. + * + * @param {object} that Reference to Stager object + * @param {string} nameAndAlias The stage-name string + * @param {string} method The name of the method calling the validation + * + * @return {object} Object with properties id and alias (if found) + * + * @see Stager.next + * @see handleAlias + * + * @api private + */ + function handleAlias(that, nameAndAlias, method) { + var tokens, id, alias; + tokens = extractAlias(nameAndAlias); + id = tokens.id; + alias = tokens.alias; + if (id === alias) { + throw new Error('Stager.' + method + ': id equal to alias: ' + + nameAndAlias); + } + if (alias && !that.stages[id]) { + throw new Error('Stager.' + method + ': alias is referencing ' + + 'non-existing stage: ' + id); + } + if (alias && that.stages[alias]) { + throw new Error('Stager.' + method + ': alias is not unique: ' + + alias); + } + return tokens; + } + + /** + * #### checkStepValidity + * + * Returns whether given step is valid + * + * Checks for syntactic validity of the step object. Does not validate + * whether the name is unique, etc. + * + * @param {object} step The step object + * @param {string} method The name of the method calling the validation + * + * @see Stager.addStep + * @see checkStageStepId + * + * @api private + */ + function checkStepValidity(step, method) { + if (step === null || 'object' !== typeof step) { + throw new TypeError('Stager.' + method + ': step must be ' + + 'object. Found: ' + step); + } + if ('function' !== typeof step.cb) { + throw new TypeError('Stager.' + method + ': step.cb must be ' + + 'function. Found: ' + step.cb); + } + checkStageStepId(method, 'step', step.id); + } + + /** + * checkStageValidity + * + * Returns whether given stage is valid + * + * Checks for syntactic validity of the stage object. Does not validate + * whether the stage name is unique, the steps exists, etc. + * + * @param {object} stage The stage to validate + * @param {string} method The name of the method calling the validation + * + * @see Stager.addStage + * @see checkStageStepId + * + * @api private + */ + function checkStageValidity(stage, method) { + if ('object' !== typeof stage) { + throw new TypeError('Stager.' + method + ': stage must be ' + + 'object. Found: ' + stage); + } + if ((!stage.steps && !stage.cb) || (stage.steps && stage.cb)) { + throw new TypeError('Stager.' + method + ': stage must have ' + + 'either a steps or a cb property'); + } + if (J.isArray(stage.steps)) { + if (!stage.steps.length) { + throw new Error('Stager.' + method + ': stage.steps cannot ' + + 'be empty'); + } + } + else if (stage.steps) { + throw new TypeError('Stager.' + method + ': stage.steps must be ' + + 'array or undefined. Found: ' + stage.steps); + } + checkStageStepId(method, 'stage', stage.id); + } + + /** + * #### handleStepParameter + * + * Check validity of a stage parameter, eventually adds it if missing + * + * @param {Stager} that Stager object + * @param {string|object} step The step to validate + * @param {string} method The name of the method calling the validation + * + * @return {string} The id of the step + * + * @api private + */ + function handleStepParameter(that, step, method) { + var id; + if ('object' === typeof step) { + id = step.id; + if (that.steps[id]) { + throw new Error('Stager.' + method + ': step is object, ' + + 'but a step with the same id already ' + + 'exists: ' + id); + } + // Add default callback, if missing. + if (!step.cb) step.cb = that.getDefaultCallback(); + } + else if ('string' === typeof step) { + id = step; + step = { + id: id, + cb: that.getDefaultCallback() + }; + } + else { + throw new TypeError('Stager.' + method + ': step must be ' + + 'string or object. Found: ' + step); + } + + // A new step is created if not found (performs validation). + if (!that.steps[id]) that.addStep(step); + + return id; + } + + /** + * #### handleStageParameter + * + * Check validity of a stage parameter, eventually adds it if missing + * + * Called by: `stage`, `repeat`, `doLoop`, 'loop`. + * + * @param {Stager} that Stager object + * @param {string|object} stage The stage to validate + * @param {string} method The name of the method calling the validation + * + * @return {string} The id or alias of the stage + * + * @api private + * + * @see checkStageValidity + */ + function handleStageParameter(that, stage, method) { + var tokens, id, alias; + if ('object' === typeof stage) { + id = stage.id; + + // Check only if it is already existing + // (type checking is done later). + if (that.stages[id]) { + throw new Error('Stager.' + method + ': stage is object, ' + + 'but a stage with the same id already ' + + 'exists: ' + id); + } + + // If both cb and steps are missing, adds steps array, + // and create new step, if necessary. + if (!stage.cb && !stage.steps) { + stage.steps = [ id ]; + if (!that.steps[id]) { + that.addStep({ id: id, cb: that.getDefaultCb() }); + } + } + // If a cb property is present create a new step with that cb. + // If a step with same id is already existing, raise an error. + else if (stage.cb) { + if (that.steps[id]) { + throw new Error('Stager.' + method + ': stage has ' + + 'cb property, but a step with the same ' + + 'id is already defined: ' + id); + } + that.addStep({ id: id, cb: stage.cb }); + delete stage.cb; + stage.steps = [ id ]; + } + that.addStage(stage); + } + else if ('string' === typeof stage) { + + // See whether the stage id contains an alias. Throws errors. + tokens = handleAlias(that, stage, method); + alias = tokens.alias; + id = tokens.id; + // Alias must reference an existing stage (checked before). + if (alias) { + that.stages[alias] = that.stages[id]; + } + else if (!that.stages[id]) { + // Add the step if not existing and flag it as default. + if (!that.steps[id]) { + that.addStep(makeDefaultStep(id, that.getDefaultCb())); + } + that.addStage({ + id: id, + steps: [ id ] + }); + } + } + else { + throw new TypeError('Stager.' + method + ': stage must be ' + + 'string or object. Found: ' + stage); + } + + return alias || id; + } + + /** + * #### checkStageStepId + * + * Check the validity of the ID of a step or a stage + * + * Must be non-empty string, and cannot begin with a dot. + * + * Notice: in the future, the following limitations might apply: + * + * - no dots at all in the name + * - cannot begin with a number + * + * @param {string} method The name of the invoking method + * @param {string} s A string taking value 'step' or 'stage' + * @param {string} id The id to check + */ + function checkStageStepId(method, s, id) { + if ('string' !== typeof id) { + throw new TypeError('Stager.' + method + ': ' + s + '.id must ' + + 'be string. Found: ' + id); + } + if (id.trim() === '') { + throw new TypeError('Stager.' + method + ': ' + s + '.id cannot ' + + 'be an empty string.'); + } + if (id.lastIndexOf('.') !== -1) { + throw new Error('Stager.' + method + ': ' + s + '.id cannot ' + + 'contains dots. Found: ' + id); + } + if (/^\d+$/.test(id.charAt(0))) { + throw new Error('Stager.' + method + ': ' + s + '.id cannot ' + + 'begin with a number. Found: ' + id); + } + } + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Stager Setter and Getters + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + */ +(function(exports, node) { + + var J = node.JSUS; + var Stager = node.Stager; + var stepRules = node.stepRules; + + // Referencing shared entities. + var isDefaultCb = Stager.isDefaultCb; + var makeDefaultCb = Stager.makeDefaultCb; + + /** + * #### Stager.setState + * + * Sets the internal state of the Stager + * + * The passed state object can have the following fields: + * steps, stages, sequence, generalNextFunction, nextFunctions, + * defaultStepRule, defaultGlobals, defaultProperties, onInit, + * onGameover. + * All fields are optional. + * + * This function calls the corresponding functions to set these + * fields, and performs error checking. + * + * If updateRule is 'replace', the Stager is cleared before applying + * the state. + * + * @param {object} stateObj The Stager's state + * @param {string} updateRule Optional. Whether to + * 'replace' (default) or to 'append'. + * + * @see Stager.getState + */ + Stager.prototype.setState = function(stateObj, updateRule) { + var idx; + var stageObj, seqObj, blockObj; + + if ('object' !== typeof stateObj) { + throw new TypeError('Stager.setState: stateObj must be object. ' + + 'Found: ' + stageObj); + } + + updateRule = updateRule || 'replace'; + + if ('string' !== typeof updateRule) { + throw new TypeError('Stager.setState: updateRule must be string ' + + 'or undefined. Found: ' + updateRule); + } + + // Clear previous state: + if (updateRule === 'replace') { + this.clear(); + } + else if (updateRule !== 'append') { + throw new Error('Stager.setState: invalid updateRule: ' + + updateRule); + } + + // Add steps: + for (idx in stateObj.steps) { + if (stateObj.steps.hasOwnProperty(idx)) { + this.addStep(stateObj.steps[idx]); + } + } + + // Add stages: + // first, handle all non-aliases + // (key of `stages` entry is same as `id` field of its value) + for (idx in stateObj.stages) { + stageObj = stateObj.stages[idx]; + if (stateObj.stages.hasOwnProperty(idx) && + stageObj.id === idx) { + this.addStage(stageObj); + } + } + // second, handle all aliases + // (key of `stages` entry is different from `id` field of + // its value) + for (idx in stateObj.stages) { + stageObj = stateObj.stages[idx]; + if (stateObj.stages.hasOwnProperty(idx) && + stageObj.id !== idx) { + this.stages[idx] = this.stages[stageObj.id]; + } + } + + // Set openStepBlocks. + if (stateObj.hasOwnProperty('openStepBlocks')) { + this.openStepBlocks = stateObj.openStepBlocks; + } + + // Add sequence: + if (stateObj.hasOwnProperty('sequence')) { + for (idx = 0; idx < stateObj.sequence.length; idx++) { + seqObj = stateObj.sequence[idx]; + this.sequence[idx] = seqObj; + } + } + + // Set general next-decider: + if (stateObj.hasOwnProperty('generalNextFunction')) { + this.registerGeneralNext(stateObj.generalNextFunction); + } + + // Set specific next-deciders: + for (idx in stateObj.nextFunctions) { + if (stateObj.nextFunctions.hasOwnProperty(idx)) { + this.registerNext(idx, stateObj.nextFunctions[idx]); + } + } + + // Set default step-rule: + if (stateObj.hasOwnProperty('defaultStepRule')) { + this.setDefaultStepRule(stateObj.defaultStepRule); + } + + // Set default globals: + if (stateObj.hasOwnProperty('defaultGlobals')) { + this.setDefaultGlobals(stateObj.defaultGlobals); + } + + // Set default properties: + if (stateObj.hasOwnProperty('defaultProperties')) { + this.setDefaultProperties(stateObj.defaultProperties); + } + + // Set onInit: + if (stateObj.hasOwnProperty('onInit')) { + this.setOnInit(stateObj.onInit); + } + + // Set onGameover: + if (stateObj.hasOwnProperty('onGameover')) { + this.setOnGameover(stateObj.onGameover); + } + + // Set toSkip. + if (stateObj.hasOwnProperty('toSkip')) { + this.toSkip = stateObj.toSkip; + } + + // Set defaultCallback. + if (stateObj.hasOwnProperty('defaultCallback')) { + this.setDefaultCallback(stateObj.defaultCallback); + } + + // Cache reset. + if (stateObj.hasOwnProperty('cacheReset')) { + this.cacheReset = stateObj.cacheReset; + } + + // Blocks. + if (stateObj.hasOwnProperty('blocks')) { + this.blocksIds = {}; + for (idx = 0; idx < stateObj.blocks.length; idx++) { + blockObj = stateObj.blocks[idx]; + this.blocks[idx] = blockObj; + // Save block id into the blocks map. + this.blocksIds[blockObj.id] = idx; + } + } + if (stateObj.hasOwnProperty('currentStage')) { + this.currentStage = stateObj.currentStage; + } + if (stateObj.hasOwnProperty('currentBlockType')) { + this.currentBlockType = stateObj.currentBlockType; + } + + // Mark finalized. + this.finalized = true; + }; + + /** + * #### Stager.getState + * + * Finalizes the stager and returns a copy of internal state + * + * // TODO: the finalize param does not do what expected + * @param {boolean} finalize. If TRUE, it calls finalize before + * cloning the stager. Default: TRUE. + * + * @return {object} Clone of the Stager's state + * + * @see Stager.setState + * @see Stager.finalize + */ + Stager.prototype.getState = function(finalize) { + var out, i, len; + finalize = 'undefined' === typeof finalize ? true : !!finalize; + if (finalize) this.finalize(); + + out = J.clone({ + steps: this.steps, + stages: this.stages, + sequence: this.sequence, + generalNextFunction: this.generalNextFunction, + nextFunctions: this.nextFunctions, + defaultStepRule: this.defaultStepRule, + defaultGlobals: this.defaultGlobals, + defaultProperties: this.defaultProperties, + onInit: this.onInit, + onGameover: this.onGameover, + toSkip: this.toSkip, + defaultCallback: this.defaultCallback, + cacheReset: this.cacheReset, + currentStage: this.currentStage, + currentBlockType: this.currentBlockType, + openStepBlocks: this.openStepBlocks + }); + + // Cloning blocks separately. + out.blocks = []; + i = -1, len = this.blocks.length; + for ( ; ++i < len ; ) { + out.blocks.push(this.blocks[i].clone()); + } + if (!finalize) { + out.unfinishedBlocks = []; + i = -1, len = this.unfinishedBlocks.length; + for ( ; ++i < len ; ) { + out.unfinishedBlocks.push(this.unfinishedBlocks[i].clone()); + } + } + + return out; + }; + + /** + * #### Stager.setDefaultStepRule + * + * Sets the default step-rule function + * + * @param {function} stepRule Optional. The step-rule function. + * If undefined, the `SOLO` rule is set. + * + * @see Stager.defaultStepRule + * @see stepRules + */ + Stager.prototype.setDefaultStepRule = function(stepRule) { + if (stepRule) { + if ('function' !== typeof stepRule) { + throw new TypeError('Stager.setDefaultStepRule: ' + + 'stepRule must be function or ' + + 'undefined. Found: ' + stepRule); + } + + this.defaultStepRule = stepRule; + } + else { + // Initial default. + this.defaultStepRule = stepRules.SOLO; + } + }; + + /** + * #### Stager.getDefaultStepRule + * + * Returns the default step-rule function + * + * @return {function} The default step-rule function + */ + Stager.prototype.getDefaultStepRule = function() { + return this.defaultStepRule; + }; + + /** + * #### Stager.setDefaultCallback + * + * Sets the default callback + * + * The callback immediately replaces the current callback + * in all the steps that have a default callback. + * + * Function will be modified and flagged as `default`. + * + * @param {function|null} cb The default callback or null to unset it + * + * @see Stager.defaultCallback + * @see Stager.getDefaultCallback + * @see makeDefaultCallback + */ + Stager.prototype.setDefaultCallback = function(cb) { + var i; + if (cb === null) { + cb = Stager.defaultCallback; + } + else if ('function' !== typeof cb) { + throw new TypeError('Stager.setDefaultCallback: defaultCallback ' + + 'must be function or null. Found: ' + cb); + } + this.defaultCallback = makeDefaultCb(cb); + + for (i in this.steps) { + if (this.steps.hasOwnProperty(i)) { + if (isDefaultCb(this.steps[i].cb)) { + this.steps[i].cb = this.defaultCallback; + } + } + } + }; + + /** + * #### Stager.getDefaultCallback | getDefaultCb + * + * Returns the default callback + * + * If the default callback is not set return the static function + * `Stager.defaultCallback` + * + * @return {function} The default callback + * + * @see Stager.defaultCallback (static) + * @see Stager.defaultCallback + * @see Stager.setDefaultCallback + */ + Stager.prototype.getDefaultCb = + Stager.prototype.getDefaultCallback = function() { + return this.defaultCallback || Stager.defaultCallback; + }; + + /** + * #### Stager.setDefaultGlobals + * + * Sets/mixes in the default globals + * + * @param {object} defaultGlobals The map of default global + * variables + * @param {boolean} mixin Optional. If TRUE, parameter defaultGlobals + * will be mixed-in with current globals, otherwise it will replace + it. Default FALSE. + * + * @see Stager.defaultGlobals + * @see GamePlot.getGlobal + */ + Stager.prototype.setDefaultGlobals = function(defaultGlobals, mixin) { + if (!defaultGlobals || 'object' !== typeof defaultGlobals) { + throw new TypeError('Stager.setDefaultGlobals: defaultGlobals ' + + 'must be object. Found: ' + defaultGlobals); + } + if (mixin) J.mixin(this.defaultGlobals, defaultGlobals); + else this.defaultGlobals = defaultGlobals; + }; + + /** + * #### Stager.getDefaultGlobals + * + * Returns the default globals + * + * @return {object} The map of default global variables + * + * @see Stager.defaultGlobals + * @see GamePlot.getGlobal + */ + Stager.prototype.getDefaultGlobals = function() { + return this.defaultGlobals; + }; + + /** + * #### Stager.setDefaultProperty + * + * Sets a default property + * + * @param {string} name The name of the default property + * @param {mixed} value The value for the default property + * + * @see Stager.defaultProperties + * @see Stager.setDefaultProperties + * @see GamePlot.getProperty + */ + Stager.prototype.setDefaultProperty = function(name, value) { + if ('string' !== typeof name) { + throw new TypeError('Stager.setDefaultProperty: name ' + + 'must be string. Found: ' + name); + } + this.defaultProperties[name] = value; + }; + + /** + * #### Stager.setDefaultProperties + * + * Sets the default properties + * + * @param {object} defaultProperties The map of default properties + * @param {boolean} mixin Optional. If TRUE, parameter defaulProperties + * will be mixed-in with current globals, otherwise it will replace + it. Default FALSE. + * + * @see Stager.defaultProperties + * @see GamePlot.getProperty + */ + Stager.prototype.setDefaultProperties = function(defaultProperties, + mixin) { + if (!defaultProperties || + 'object' !== typeof defaultProperties) { + throw new TypeError('Stager.setDefaultProperties: ' + + 'defaultProperties must be object. Found: ' + + defaultProperties); + } + if (mixin) J.mixin(this.defaultProperties, defaultProperties); + else this.defaultProperties = defaultProperties; + }; + + /** + * #### Stager.getDefaultProperties + * + * Returns the default properties + * + * @return {object} The map of default properties + * + * @see Stager.defaultProperties + * @see GamePlot.getProperty + */ + Stager.prototype.getDefaultProperties = function() { + return this.defaultProperties; + }; + + /** + * #### Stager.setOnInit + * + * Sets onInit function + * + * @param {function|null} func The onInit function. + * NULL can be given to signify non-existence. + * + * @see Stager.onInit + */ + Stager.prototype.setOnInit = function(func) { + if (func && 'function' !== typeof func) { + throw new TypeError('Stager.setOnInit: func must be ' + + 'function or undefined. Found: ' + func); + } + this.onInit = func; + }; + + /** + * #### Stager.getOnInit + * + * Gets onInit function + * + * @return {function|null} The onInit function. + * NULL signifies non-existence. + * + * @see Stager.onInit + */ + Stager.prototype.getOnInit = function() { + return this.onInit; + }; + + /** + * #### Stager.setOnGameover + * + * Sets onGameover function + * + * @param {function|null} func The onGameover function. + * NULL can be given to signify non-existence. + * + * @see Stager.onGameover + */ + Stager.prototype.setOnGameover = function(func) { + if (func && 'function' !== typeof func) { + throw new Error('Stager.setOnGameover: func must be ' + + 'function or undefined.'); + } + this.onGameover = func; + }; + + /** + * #### Stager.setOnGameOver + * + * Alias for `setOnGameover` + * + * @see Stager.setOnGameover + */ + Stager.prototype.setOnGameOver = Stager.prototype.setOnGameover; + + /** + * #### Stager.getOnGameover + * + * Gets onGameover function + * + * @return {function|null} The onGameover function, or NULL if none + * is found + * + * @see Stager.onGameover + */ + Stager.prototype.getOnGameover = function() { + return this.onGameover; + }; + + /** + * #### Stager.getOnGameOver + * + * Alias for `getOnGameover` + * + * @see Stager.getOnGameover + */ + Stager.prototype.getOnGameOver = Stager.prototype.getOnGameover; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Stager flexible mode + * Copyright(c) 2019 Stefano Balietti + * MIT Licensed + */ +(function(exports, node) { + + var Stager = node.Stager; + + /** + * #### Stager.registerGeneralNext + * + * Sets general callback for next stage decision + * + * Available only when nodegame is executed in _flexible_ mode. + * The callback given here is used to determine the next stage. + * + * @param {function|null} func The decider callback. It should + * return the name of the next stage, 'NODEGAME_GAMEOVER' to end + * the game or FALSE for sequence end. NULL can be given to + * signify non-existence. + */ + Stager.prototype.registerGeneralNext = function(func) { + if (func !== null && 'function' !== typeof func) { + throw new TypeError('Stager.registerGeneralNext: ' + + 'func must be function or undefined. Found: ' + + func); + } + this.generalNextFunction = func; + }; + + /** + * #### Stager.registerNext + * + * Registers a step-decider callback for a specific stage + * + * The function overrides the general callback for the specific + * stage, and determines the next stage. + * Available only when nodegame is executed in _flexible_ mode. + * + * @param {string} id The name of the stage after which the decider + * function will be called + * @param {function} func The decider callback. It should return the + * name of the next stage, 'NODEGAME_GAMEOVER' to end the game or + * FALSE for sequence end. + * + * @see Stager.registerGeneralNext + */ + Stager.prototype.registerNext = function(id, func) { + if ('function' !== typeof func) { + throw new TypeError('Stager.registerNext: func must be ' + + 'function. Found: ' + func); + } + + if (!this.stages[id]) { + throw new TypeError('Stager.registerNext: non existent ' + + 'stage id: ' + id); + } + + this.nextFunctions[id] = func; + }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Stager extend stages, modify sequence + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + */ +(function(exports, node) { + + var J = node.JSUS; + var Stager = node.Stager; + + var checkFinalized = Stager.checkFinalized; + var handleStepsArray = Stager.handleStepsArray; + var addStepToBlock = Stager.addStepToBlock; + var isDefaultStep = Stager.isDefaultStep; + var unmakeDefaultStep = Stager.unmakeDefaultStep; + + /** + * #### Stager.extendStep + * + * Extends an existing step + * + * Notice: properties `id` cannot be modified, and property `cb` + * must always be a function. + * + * @param {string} stepId The id of the step to update + * @param {object|function} update The object containing the + * properties to update, or an update function that takes a copy + * of current step and returns the whole new updated step + * + * @see Stager.addStep + * @see validateExtendedStep + */ + Stager.prototype.extendStep = function(stepId, update) { + var step; + if ('string' !== typeof stepId) { + throw new TypeError('Stager.extendStep: stepId must be ' + + 'string. Found: ' + stepId); + } + step = this.steps[stepId]; + if (!step) { + throw new Error('Stager.extendStep: stepId not found: ' + + stepId); + } + if ('function' === typeof update) { + step = update(J.clone(step)); + validateExtendedStep(stepId, step, true); + this.steps[stepId] = step; + } + else if (update && 'object' === typeof update) { + validateExtendedStep(stepId, update, false); + J.mixin(step, update); + } + else { + throw new TypeError('Stager.extendStep: step "' + stepId + + '": update must be object ' + + 'or function. Found: ' + update); + } + }; + + /** + * #### Stager.extendStage + * + * Extends an existing stage + * + * Notice: properties `id` and `cb` cannot be modified / added. + * + * @param {string} stageId The id of the stage to update + * @param {object|function} update The object containing the + * properties to update, or an update function that takes a copy + * of current stage and returns the whole new updated stage + * + * @see Stager.addStage + * @see validateExtendedStage + */ + Stager.prototype.extendStage = function(stageId, update) { + var stage; + + if ('string' !== typeof stageId) { + throw new TypeError('Stager.extendStage: stageId must be ' + + 'string. Found: ' + stageId); + } + stage = this.stages[stageId]; + if (!stage) { + throw new Error('Stager.extendStage: stageId not found: ' + + stageId); + } + + if ('function' === typeof update) { + stage = update(J.clone(stage)); + if (!stage || 'object' !== typeof stage || + !stage.id || !stage.steps) { + + throw new TypeError('Stager.extendStage: update function ' + + 'must return an object with id and ' + + 'steps. Found: ' + stage); + } + validateExtendedStage(this, stageId, stage, true); + this.stages[stageId] = stage; + + } + else if (update && 'object' === typeof update) { + validateExtendedStage(this, stageId, update, false); + J.mixin(stage, update); + } + else { + throw new TypeError('Stager.extendStage: stage "' + stageId + + '": update must be object ' + + 'or function. Found: ' + update); + } + }; + + /** + * #### Stager.extendAllSteps + * + * Extends all existing steps + * + * @param {object|function} update The object containing the + * properties to update, or an update function that takes a copy + * of current step and returns the whole new updated step + * + * @see Stager.addStep + * @see Stager.extendStep + */ + Stager.prototype.extendAllSteps = function(update) { + var step; + for (step in this.steps) { + if (this.steps.hasOwnProperty(step)) { + this.extendStep(step, update); + } + } + }; + + /** + * #### Stager.extendSteps + * + * Extends steps with given ids + * + * @param {array} stepIds The ids of the steps to update. + * @param {object|function} update The object containing the + * properties to update, or an update function that takes a copy + * of current step and returns the whole new updated step + * + * @see Stager.addStep + * @see Stager.extendStep + */ + Stager.prototype.extendSteps = function(stepIds, update) { + if (!J.isArray(stepIds)) { + throw new TypeError('Stager.extendSteps: stepIds ' + + 'must be array. Found: ' + stepIds); + } + stepIds.forEach((stepId) => { + if (!this.steps[stepId]) { + console.log('**warn: Stager.extendSteps: step with id " '+ + stepId + '" not found'); + } + else { + this.extendStep(stepId, update); + } + + }); + }; + + /* #### Stager.extendAllStages + * + * Extends all existing stages + * + * @param {object|function} update The object containing the + * properties to update, or an update function that takes a copy + * of current stage and returns the whole new updated stage + * + * @see Stager.addStage + * @see Stager.extendStage + */ + Stager.prototype.extendAllStages = function(update) { + var stage; + for (stage in this.stages) { + if (this.stages.hasOwnProperty(stage)) { + this.extendStage(stage, update); + } + } + }; + + /** + * #### Stager.extendStages + * + * Extends steps with given ids + * + * @param {array} stageIds The ids of the stages to update. + * @param {object|function} update The object containing the + * properties to update, or an update function that takes a copy + * of current stage and returns the whole new updated stage + * + * @see Stager.extendStage + */ + Stager.prototype.extendStages = function(stageIds, update) { + if (!J.isArray(stageIds)) { + throw new TypeError('Stager.extendSteps: stageIds ' + + 'must be array. Found: ' + stageIds); + } + stageIds.forEach((stageId) => { + if (!this.stages[stageId]) { + console.log('**warn: Stager.extendStages: stage with id " '+ + stageId + '" not found'); + } + else { + this.extendStage(stageId, update); + } + }); + }; + + /** + * #### Stager.skip + * + * Marks a stage or as step as `toSkip` and won't be added to sequence + * + * Must be called before invoking `Stager.finalize()`. + * + * @param {string|array} stageId The id/s of the stage to skip + * @param {string|array} stepId Optional. The id/s of the step within + * the stage to skip. Notice stepId and stageId cannot be both arrays. + * + * @see Stager.unskip + * @see Stager.finalize + */ + Stager.prototype.skip = function(stageId, stepId) { + checkFinalized(this, 'skip'); + setSkipStageStepArray(this, stageId, stepId, true, 'skip'); + + }; + + /** + * #### Stager.unskip + * + * Unskips a stage or step + * + * Must be called before invoking `Stager.finalize()`. + * + * @param {string|array} stageId The id/s of the stage to skip + * @param {string|array} stepId Optional. The id/s of the step within + * the stage to unskip. Notice stepId and stageId cannot be both arrays. + * + * @see Stager.skip + * @see Stager.finalize + */ + Stager.prototype.unskip = function(stageId, stepId) { + checkFinalized(this, 'unskip'); + setSkipStageStepArray(this, stageId, stepId, false, 'unskip'); + }; + + /** + * #### Stager.isSkipped + * + * Returns TRUE if a stage or step is currently marked as `toSkip` + * + * @param {string} stageId The id of the stage + * @param {string} stepId Optional. The id of the step within the stage + * + * @return {boolean} TRUE, if the stage or step is marked as `toSkip` + * + * @see Stager.skip + * @see Stager.unskip + */ + Stager.prototype.isSkipped = function(stageId, stepId) { + return !!setSkipStageStep(this, stageId, stepId, undefined, + 'isSkipped'); + }; + + // ## Helper functions. + + /** + * #### setSkipStageStep + * + * Sets/Gets the value for the flag `toSkip` for a stage or a step + * + * @param {Stager} that Stager object + * @param {string} stageId The id of the stage + * @param {string} stepId Optional. The id of the step within the stage + * @param {mixed} value If defined, is assigned to the stage or step + * @param {string} method The name of the method calling the validation + * + * @return {boolean|null} The current value for the stage or step + * + * @api private + */ + function setSkipStageStepArray(that, stageId, stepId, value, method) { + + if (J.isArray(stageId) && stageId.length === 1) stageId = stageId[0]; + if (J.isArray(stepId) && stepId.length === 1) stepId = stepId[0]; + + if (J.isArray(stageId) && J.isArray(stepId)) { + throw new Error('Staker.' + method + ': stageId and stepId ' + + 'cannot be both arrays of length > 1'); + } + if (J.isArray(stageId)) { + stageId.forEach((_stageId) => { + setSkipStageStep(that, _stageId, stepId, value, method); + }); + } + else if (J.isArray(stepId)) { + stepId.forEach((_stepId) => { + setSkipStageStep(that, stageId, _stepId, value, method); + }); + } + else { + setSkipStageStep(that, stageId, stepId, value, method); + } + } + + /** + * #### setSkipStageStep + * + * Sets/Gets the value for the flag `toSkip` for a stage or a step + * + * @param {Stager} that Stager object + * @param {string} stageId The id of the stage + * @param {string} stepId Optional. The id of the step within the stage + * @param {mixed} value If defined, is assigned to the stage or step + * @param {string} method The name of the method calling the validation + * + * @return {boolean|null} Whether the stage or step is currently skipped. + * A step can be skipped if its stage is skipped; vice versa, a stage + * can be skipped if all of its steps are skipped. + * + * @api private + */ + function setSkipStageStep(that, stageId, stepId, value, method) { + var allStepsSkipped, steps, i; + if ('string' !== typeof stageId || stageId.trim() === '') { + throw new TypeError('Stager.' + method + ': stageId must ' + + 'be a non-empty string. Found: ' + stageId); + } + if (!that.stages[stageId]) { + console.log('**warn: Stager.' + method + ': unknown stage: "' + + stageId + '" (you may still add it later)'); + } + if (stepId) { + if ('string' !== typeof stepId || stepId.trim() === '') { + throw new TypeError('Stager.' + method + ': stepId must ' + + 'be a non-empty string or undefined.' + + 'Found: ' + stepId); + } + + if (!that.steps[stepId]) { + console.log('**warn: Stager.' + method + ': unknown step: "' + + stepId + '" (you may still add it later)'); + } + + if ('undefined' !== typeof value) { + that.toSkip.steps[stageId + '.' + stepId] = value; + } + + // A step may be skipped if the stage is skipped. + return that.toSkip.stages[stageId] || + that.toSkip.steps[stageId + '.' + stepId]; + } + + // Set the value. + if ('undefined' !== typeof value) that.toSkip.stages[stageId] = value; + + // If the stage was previously set to be skipped return TRUE. + if (that.toSkip.stages[stageId]) return true; + + // A stage can be skipped if all of its steps are skipped. + allStepsSkipped = true; + steps = that.stages[stageId].steps; + for (i = 0; i < steps.length; i++) { + // First step might be the default one and will be removed + // if there are additional steps. + if (i === 0 && steps.length > 1) { + if (isDefaultStep(that.steps[steps[i]])) continue; + } + if (!that.toSkip.steps[stageId + '.' + steps[i]]) { + allStepsSkipped = false; + break; + } + } + + return allStepsSkipped; + } + + + /** + * #### validateExtendedStep + * + * Validates the modification to a step (already known as object) + * + * Each step inside the steps array is validated via `handleStepsArray`. + * + * @param {string} stepId The original step id + * @param {object} update The update/updated object + * @param {boolean} updateFunction TRUE if the update object is the + * value returned by an update function + * + * @see handleStepsArray + */ + function validateExtendedStep(stepId, update, updateFunction) { + var errBegin; + if (updateFunction) { + errBegin = 'Stager.extendStep: update function must return ' + + 'an object with '; + + if (!update || 'object' !== typeof update) { + throw new TypeError(errBegin + 'id and cb. Found: ' + update + + '. Step id: ' + stepId); + } + if (update.id !== stepId) { + throw new Error('Stager.extendStep: update function ' + + 'cannot alter the step id: ' + stepId); + } + if ('function' !== typeof update.cb) { + throw new TypeError(errBegin + 'a valid callback. Step id:' + + stepId); + } + if (update.init && 'function' !== typeof update.init) { + throw new TypeError(errBegin + 'invalid init property. ' + + 'Function or undefined expected, found: ' + + typeof update.init + '. Step id:' + + stepId); + } + if (update.exit && 'function' !== typeof update.exit) { + throw new TypeError(errBegin + 'invalid exit property. ' + + 'Function or undefined expected, found: ' + + typeof update.exit + '. Step id:' + + stepId); + } + if (update.done && 'function' !== typeof update.done) { + throw new TypeError(errBegin + 'invalid done property. ' + + 'Function or undefined expected, found: ' + + typeof update.done + '. Step id:' + + stepId); + } + } + else { + if (update.hasOwnProperty('id')) { + throw new Error('Stager.extendStep: update.id cannot be set. ' + + 'Step id: ' + stepId); + } + if (update.cb && 'function' !== typeof update.cb) { + throw new TypeError('Stager.extendStep: update.cb must be ' + + 'function or undefined. Step id:' + + stepId); + } + if (update.init && 'function' !== typeof update.init) { + throw new TypeError('Stager.extendStep: update.init must be ' + + 'function or undefined. Step id:' + + stepId); + } + if (update.exit && 'function' !== typeof update.exit) { + throw new TypeError('Stager.extendStep: update.exit must be ' + + 'function or undefined. Step id:' + + stepId); + } + if (update.done && 'function' !== typeof update.done) { + throw new TypeError('Stager.extendStep: update.done must be ' + + 'function or undefined. Step id:' + + stepId); + } + + } + } + + /** + * #### validateExtendedStage + * + * Validates the modification to a stage (already known as object) + * + * Each step inside the steps array is validated via `handleStepsArray`. + * + * @param {Stager} that Stager object + * @param {string} stageId The original stage id + * @param {object} update The update/updated object + * @param {boolean} updateFunction TRUE if the update object is the + * value returned by an update function + * + * @see handleStepsArray + */ + function validateExtendedStage(that, stageId, update, updateFunction) { + var block, i, len; + if ((updateFunction && update.id !== stageId) || + (!updateFunction && update.hasOwnProperty('id'))) { + + throw new Error('Stager.extendStage: id cannot be altered: ' + + stageId); + } + if (update.cb) { + throw new TypeError('Stager.extendStage: update.cb cannot be ' + + 'specified. Stage id: ' + stageId); + } + if (update.init && 'function' !== typeof update.init) { + throw new TypeError('Stager.extendStage: update.init must be ' + + 'function or undefined. Stage id:' + + stageId); + } + if (update.exit && 'function' !== typeof update.exit) { + throw new TypeError('Stager.extendStage: update.exit must be ' + + 'function or undefined. Stage id:' + + stageId); + } + if (update.done && 'function' !== typeof update.done) { + throw new TypeError('Stager.extendStage: update.done must be ' + + 'function or undefined. Stage id:' + + stageId); + } + if (update.steps) { + if (!J.isArray(update.steps)) { + throw new Error('Stager.extendStage: update.steps must be ' + + 'array or undefined. Stage id: ' + stageId + + 'Found: ' + update.steps); + } + + if (!update.steps.length) { + throw new Error('Stager.extendStage: update.steps is an ' + + 'empty array. Stage id: ' + stageId); + } + + // No changes to the steps array, just exit. + if (J.equals(that.stages[stageId].steps, update.steps)) return; + + // Process every step in the array. Steps array is modified. + handleStepsArray(that, stageId, update.steps, 'extendStage'); + + // We need to get the enclosing steps block, + // following the stage block. + block = that.findBlockWithItem(stageId); + + // Stage is not in any block, just exit. + if (!block) return; + + // We need to update the block in which the stage was. + + if ('undefined' !== typeof block.unfinishedItems[1]) { + block = block.unfinishedItems[1].item; + } + // The stage block was not ended yet, + // so the the step block is the last of the sequence. + else { + block = that.blocks[that.blocks.length -1]; + } + + // Remove all previous steps before adding the updated steps. + block.removeAllItems(); + + // Add steps to block (if necessary). + i = -1, len = update.steps.length; + for ( ; ++i < len ; ) { + // If the default step is contained in the list of updated + // steps, then it's not a default step and we keep it. + if (isDefaultStep(that.steps[update.steps[i]])) { + unmakeDefaultStep(that.steps[update.steps[i]]); + } + addStepToBlock(that, block, update.steps[i], stageId); + } + } + } + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Stager blocks operations + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + */ +(function(exports, node) { + + var Stager = node.Stager; + + var checkPositionsParameter = Stager.checkPositionsParameter; + var addStageBlock = Stager.addStageBlock; + var addBlock = Stager.addBlock; + + var blockTypes = Stager.blockTypes; + var BLOCK_DEFAULT = blockTypes.BLOCK_DEFAULT; + var BLOCK_STAGEBLOCK = blockTypes.BLOCK_STAGEBLOCK; + var BLOCK_STEPBLOCK = blockTypes.BLOCK_STEPBLOCK; + var BLOCK_STEP = blockTypes.BLOCK_STEP; + var BLOCK_ENCLOSING = blockTypes.BLOCK_ENCLOSING; + var BLOCK_ENCLOSING_STEPS = blockTypes.BLOCK_ENCLOSING_STEPS; + + /** + * #### Stager.stepBlock + * + * Begins a new Block of steps + * + * This function just validates the input paramters and passes them + * to lower level function `addBlock`. + * + * @param {string} id The id of the block. + * @param {string|number} positions Optional. Positions within the + * enclosing Block that this block may occupy. + * + * @return {Stager} Reference to the current instance for method chaining + */ + Stager.prototype.stepBlock = function(id, positions) { + var curBlock, err; + + if (arguments.length === 1) { + console.log('***deprecation warning: Stager.stepBlock will ' + + 'require two parameters in the next version.***'); + + positions = id; + } + else { + if ('string' !== typeof id || id.trim() === '') { + throw new TypeError('Stager.stepBlock: id must be a ' + + 'non-empty string. Found: ' + id); + } + if (this.blocksIds[id]) { + throw new Error('Stager.stepBlock: non-unique id: ' + id); + } + } + + // Check if a stage block can be added in this position. + curBlock = this.getCurrentBlock(); + + if (!curBlock || curBlock.id === BLOCK_DEFAULT || + (!curBlock.isType(BLOCK_ENCLOSING_STEPS) && + !curBlock.isType(BLOCK_STEPBLOCK))) { + + err = 'Stager.stepBlock: block '; + if (id) err += '"' + id + '"'; + err += 'cannot be added here. Did add at least one stage before?' + throw new Error(err); + } + + + // !curBlock.isType(BLOCK_STEPBLOCK)) + if (curBlock.isType(BLOCK_STEPBLOCK) && curBlock.size() === 0) { + err = 'Stager.stepBlock: block '; + if (id) err += '"' + id + '"'; + err += 'cannot be added here. Did add at least one step ' + + 'in the previous step block?' + throw new Error(err); + } + + checkPositionsParameter(positions, 'stepBlock'); + + addBlock(this, id, BLOCK_STEPBLOCK, positions, BLOCK_STEP); + this.openStepBlocks++; + + return this; + }; + + /** + * #### Stager.stageBlock + * + * Begins a new Block of stages + * + * This function just validates the input paramters and passes them + * to lower level function `addStageBlock`. + * + * @param {string} id The id of the block. + * @param {string|number} positions Optional. Positions within the + * enclosing Block that this block can occupy. + * + * @return {Stager} Reference to the current instance for method chainining + * + * @see addStageBlock + */ + Stager.prototype.stageBlock = function(id, positions) { + var curBlock, err; + + if (arguments.length === 1) { + console.log('***deprecation warning: Stager.stageBlock will ' + + 'require two parameters in the next version.***'); + + positions = id; + } + else { + if ('string' !== typeof id || id.trim() === '') { + throw new TypeError('Stager.stageBlock: id must be a ' + + 'non-empty string. Found: ' + id); + } + if (this.blocksIds[id]) { + throw new Error('Stager.stageBlock: non-unique id: ' + id); + } + } + + // Check if a stage block can be added in this position. + curBlock = this.getCurrentBlock(); + + if (curBlock && curBlock.id !== BLOCK_DEFAULT && + // (curBlock.isType(BLOCK_STAGE) || + curBlock.isType(BLOCK_STAGEBLOCK)) { + + err = 'Stager.stageBlock: block '; + if (id) err += '"' + id + '"'; + err += 'cannot be added here. Did add at least one stage ' + + 'in the previous stage block?' + throw new Error(err); + } + + checkPositionsParameter(positions, 'stageBlock'); + + // Closes last step and stage blocks. + // Then adds a new enclosing-stages block. + addStageBlock(this, id, BLOCK_STAGEBLOCK, positions); + + return this; + }; + + /** + * #### Stager.getCurrentBlock + * + * Returns the Block that Stager is currently working on + * + * @return {object|boolean} Currently open block, or FALSE if no + * unfinished block is found + */ + Stager.prototype.getCurrentBlock = function() { + if (this.unfinishedBlocks.length === 0) return false; + return this.unfinishedBlocks[this.unfinishedBlocks.length -1]; + }; + + /** + * #### Stager.endBlock + * + * Ends the current Block + * + * param {object} options Optional If `options.finalize` is set, the + * block gets finalized. + * + * @return {Stager} Reference to the current instance for method chainining + */ + Stager.prototype.endBlock = function(options) { + var block, currentBlock; + var found, i; + if (!this.unfinishedBlocks.length) return this; + options = options || {}; + + block = this.unfinishedBlocks.pop(); + + // Step block. + if (block.isType(BLOCK_STEPBLOCK)) { + + // We find the first enclosing block for the step block + // (in between there could several steps). + i = this.blocks.length-1; + do { + currentBlock = this.blocks[i]; + found = currentBlock.id.indexOf(BLOCK_ENCLOSING) !== -1; + i--; + } + while (!found && i >= 0) + + if (found) { + currentBlock.add(block, block.positions); + } + else { + throw new Error('Stager.endBlock: could not find enclosing ' + + 'block for stepBlock ' + block.name); + } + + } + // Normal stage / step block, add it to previous + else if (!block.isType(BLOCK_STAGEBLOCK)) { + + currentBlock = this.getCurrentBlock(); + if (currentBlock) currentBlock.add(block, block.positions); + + } + // Add stage block to default block. + else if (block.id !== BLOCK_DEFAULT && + block.id.indexOf(BLOCK_ENCLOSING) === -1) { + + this.blocks[0].add(block, block.positions); + } + if (options.finalize) block.finalize(); + return this; + }; + + /** + * #### Stager.endBlocks + * + * Ends multiple unfinished Blocks + * + * @param Number n Number of unfinished Blocks to be ended. + * @param {object} options Optional If options.finalize is set, the + * block gets finalized. + */ + Stager.prototype.endBlocks = function(n, options) { + var i; + for (i = 0; i < n; ++i) { + this.endBlock(options); + } + return this; + }; + + /** + * #### Stager.endAllBlocks + * + * Ends all unfinished Blocks. + */ + Stager.prototype.endAllBlocks = function() { + this.endBlocks(this.unfinishedBlocks.length); + }; + + /** + * #### Stager.findBlockWithItem + * + * Returns the block where the item (step|stage) with specified id is found + * + * @param {string} itemId The id of the item + * + * @return {object|boolean} The block containing the requested item, + * or FALSE if none is found + */ + Stager.prototype.findBlockWithItem = function(itemId) { + var i, len; + i = -1, len = this.blocks.length; + for ( ; ++i < len ; ) { + if (this.blocks[i].hasItem(itemId)) return this.blocks[i]; + } + return false; + }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Stager Extract Info + * Copyright(c) 2019 Stefano Balietti + * MIT Licensed + */ +(function(exports, node) { + + var Stager = node.Stager; + + /** + * #### Stager.getSequence + * + * Returns the sequence of stages + * + * @param {string} format 'hstages' for an array of human-readable + * stage descriptions, 'hsteps' for an array of human-readable + * step descriptions, 'o' for the internal JavaScript object + * + * @return {array|object|null} The stage sequence in requested + * format. NULL on error. + */ + Stager.prototype.getSequence = function(format) { + var result; + var seqIdx; + var seqObj; + var stepPrefix; + + switch (format) { + case 'hstages': + result = []; + + for (seqIdx in this.sequence) { + if (this.sequence.hasOwnProperty(seqIdx)) { + seqObj = this.sequence[seqIdx]; + + switch (seqObj.type) { + case 'gameover': + result.push('[game over]'); + break; + + case 'plain': + result.push(seqObj.id); + break; + + case 'repeat': + result.push(seqObj.id + ' [x' + seqObj.num + + ']'); + break; + + case 'loop': + result.push(seqObj.id + ' [loop]'); + break; + + case 'doLoop': + result.push(seqObj.id + ' [doLoop]'); + break; + + default: + throw new Error('Stager.getSequence: unknown' + + 'sequence object type: ' + seqObj.type); + } + } + } + break; + + case 'hsteps': + result = []; + + for (seqIdx in this.sequence) { + if (this.sequence.hasOwnProperty(seqIdx)) { + seqObj = this.sequence[seqIdx]; + stepPrefix = seqObj.id + '.'; + + switch (seqObj.type) { + case 'gameover': + result.push('[game over]'); + break; + + case 'plain': + seqObj.steps.map( + function(stepID) { + result.push(stepPrefix + stepID); + } + ); + break; + + case 'repeat': + seqObj.steps.map( + function(stepID) { + result.push(stepPrefix + stepID + + ' [x' + seqObj.num + ']'); + } + ); + break; + + case 'loop': + seqObj.steps.map( + function(stepID) { + result.push(stepPrefix + + stepID + ' [loop]'); + } + ); + break; + + case 'doLoop': + seqObj.steps.map( + function(stepID) { + result.push(stepPrefix + + stepID + ' [doLoop]'); + } + ); + break; + + default: + throw new Error('Stager.getSequence: unknown' + + 'sequence object type: ' + seqObj.type); + } + } + } + break; + + case 'o': + result = this.sequence; + break; + + default: + throw new Error('Stager.getSequence: invalid format: ' + format); + } + + return result; + }; + + /** + * #### Stager.extractStage + * + * Returns a minimal state package containing one or more stages + * + * The returned package consists of a `setState`-compatible object + * with the `steps` and `stages` properties set to include the given + * stages. + * The `sequence` is optionally set to a single `next` block for the + * stage. + * + * @param {string|array} ids Valid stage name(s) + * @param {boolean} useSeq Optional. Whether to generate a singleton + * sequence. TRUE by default. + * + * @return {object|null} The state object on success, NULL on error + * + * @see Stager.setState + */ + Stager.prototype.extractStage = function(ids, useSeq) { + var result; + var stepIdx, stepId; + var stageId; + var stageObj; + var idArray, idIdx, id; + + if (ids instanceof Array) { + idArray = ids; + } + else if ('string' === typeof ids) { + idArray = [ ids ]; + } + else return null; + + result = { steps: {}, stages: {}, sequence: [] }; + + // undefined (default) -> true + useSeq = (useSeq === false) ? false : true; + + for (idIdx in idArray) { + if (idArray.hasOwnProperty(idIdx)) { + id = idArray[idIdx]; + + stageObj = this.stages[id]; + + if (!stageObj) return null; + + // Add step objects: + for (stepIdx in stageObj.steps) { + if (stageObj.steps.hasOwnProperty(stepIdx)) { + stepId = stageObj.steps[stepIdx]; + result.steps[stepId] = this.steps[stepId]; + } + } + + // Add stage object: + stageId = stageObj.id; + result.stages[stageId] = stageObj; + + // If given id is alias, also add alias: + if (stageId !== id) result.stages[id] = stageObj; + + // Add mini-sequence: + if (useSeq) { + result.sequence.push({ + type: 'plain', + id: stageId + }); + } + } + } + + return result; + }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # SocketFactory + * Copyright(c) 2015 Stefano Balietti + * MIT Licensed + * + * `nodeGame` component responsible for registering and instantiating + * new GameSocket clients + * + * Contract: Socket prototypes must implement the following methods: + * + * - connect: establish a communication channel with a ServerNode instance + * - send: pushes messages into the communication channel + */ +(function(exports) { + + "use strict"; + + // Storage for socket types. + var types = {}; + + function checkContract(Proto) { + var test; + test = new Proto(); + if (!test.send) return false; + if (!test.connect) return false; + return true; + } + + function getTypes() { + return types; + } + + function get( node, type, options ) { + var Socket = types[type]; + return (Socket) ? new Socket(node, options) : null; + } + + function register( type, proto ) { + if (!type || !proto) return; + + // only register classes that fulfill the contract + if ( checkContract(proto) ) { + types[type] = proto; + } + else { + throw new Error('Cannot register invalid Socket class: ' + type); + } + } + + // expose the socketFactory methods + exports.SocketFactory = { + checkContract: checkContract, + getTypes: getTypes, + get: get, + register: register + }; + + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports +); + +/** + * # Socket + * Copyright(c) 2020 Stefano Balietti + * MIT Licensed + * + * Wrapper class for the actual socket to send messages + * + * http://nodegame.org + */ +(function(exports, parent) { + + "use strict"; + + exports.Socket = Socket; + + // ## Global scope + + var GameMsg = parent.GameMsg, + SocketFactory = parent.SocketFactory, + J = parent.JSUS; + + /** + * ## Socket constructor + * + * Creates a new instance of Socket + * + * @param {NodeGameClient} node Reference to the node instance + */ + function Socket(node) { + + // ## Public properties + + /** + * ### Socket.buffer + * + * Buffer of queued incoming messages + * + * @api private + */ + this.buffer = []; + + /** + * ### Socket.outBuffer + * + * Buffer of queued outgoing messages + * + * TODO: implement! + * + * @api private + */ + // this.outBuffer = []; + + /** + * ### Socket.session + * + * The session id shared with the server + * + * This property is initialized only when a game starts + * + */ + this.session = null; + + /** + * ### Socket.userOptions + * + * Contains the options that will be passed to the `connect` method + * + * The property is set by `node.setup.socket`. + * Passing options to the `connect` method will overwrite this property. + * + * @see node.setup + * @see Socket.connect + */ + this.userOptions = {}; + + /** + * ### Socket.socket + * + * The actual socket object (e.g. SocketDirect, or SocketIo) + */ + this.socket = null; + + /** + * ### Socket.connected + * + * Socket connection established + * + * For realiably checking whether the connection is established + * use `Socket.isConnected()`. + * + * @see Socket.connecting + * @see Socket.isConnected + * @see Socket.onConnect + * @see Socket.onDisconnect + */ + this.connected = false; + + /** + * ### Socket.connecting + * + * Socket connection being established + * + * TODO see whether we should merge connected / connecting + * in one variable with socket states. + * + * @see Socket.connected + * @see Socket.isConnected + * @see Socket.onConnect + * @see Socket.onDisconnect + */ + this.connecting = false; + + + /** + * ### Socket.reconnecting + * + * Flags that a reconnection is in progress + * + * This is useful when `Socket.reconnect()` triggers a disconnection + * + * @see Socket.reconnect + */ + this.reconnecting = false; + + /** + * ### Socket.connectingTimeout + * + * Timeout to cancel the connecting procedure + * + * @see Socket.connecting + * @see Socket.connect + */ + this.connectingTimeout = null; + + /** + * ### Socket.connectingTimeoutMs + * + * Number of milliseconds for the connecting timeout + * + * Default: 10000 (10 seconds) + * + * @see Socket.connecting + * @see Socket.connect + */ + this.connectingTimeoutMs = 10000; + + /** + * ### Socket.url + * + * The full url to which the socket is connected + * + * This is set when a new connection attempt is started. + * + * It might not be meaningful for all types of sockets. For example, + * in case of SocketDirect, it is not an real url. + * + * @see Socket.connect + */ + this.url = null; + + /** + * ### Socket.channelName + * + * The name of the channel to which the socket is connected + * + * This is set upon a successful connection. + * + * @see Socket.startSession + */ + this.channelName = null; + + /** + * ### Socket.type + * + * The type of socket used + */ + this.type = null; + + /** + * ### Socket.emitOutMsg + * + * If TRUE, outgoing messages will be emitted upon sending + * + * This allows, for example, to modify all outgoing messages. + */ + this.emitOutMsg = false; + + /** + * ### Socket.antiSpoofing + * + * If TRUE, sid is added to each message + * + * This setting is sent over by server. + */ + this.antiSpoofing = null; + + // Experimental Journal. + // TODO: check if we need it. + this.journalOn = false; + + // Experimental + this.journal = new parent.NDDB({ + update: { + indexes: true + } + }); + if (!this.journal.player) { + this.journal.hash('to'); + } + + // this.journal.comparator('stage', function(o1, o2) { + // return parent.GameStage.compare(o1.stage, o2.stage); + // }); + + + // if (!this.journal.stage) { + // this.journal.hash('stage', function(gb) { + // if (gb.stage) { + // return parent.GameStage.toHash(gb.stage, 'S.s.r'); + // } + // }); + // } + // End Experimental Code. + + /** + * ### Socket.node + * + * Reference to the node object. + */ + this.node = node; + } + + // ## Socket methods + + /** + * ### Socket.setup + * + * Configures the socket + * + * @param {object} options Optional. Configuration options. + * + * @see node.setup.socket + */ + Socket.prototype.setup = function(options) { + if (!options) return; + if ('object' !== typeof options) { + throw new TypeError('Socket.setup: options must be object ' + + 'or undefined.'); + } + options = J.clone(options); + if (options.connectingTimeout) { + if (!J.isInt(options.connectingTimeout, 0)) { + + throw new TypeError('Socket.setup: options.connectingTimeout ' + + 'a positive number or undefined.'); + } + this.connectingTimeoutMs = options.connectingTimeout; + } + if (options.type) { + this.setSocketType(options.type, options); + options.type = null; + } + if ('undefined' !== typeof options.emitOutMsg) { + this.emitOutMsg = options.emitOutMsg; + options.emitOutMsg = null; + } + this.userOptions = options; + }; + + /** + * ### Socket.setSocketType + * + * Sets the default socket by requesting it to the Socket Factory + * + * Supported types: 'Direct', 'SocketIo'. + * + * @param {string} type The name of the socket to use. + * @param {object} options Optional. Configuration options for the socket. + * + * @return {object} The newly created socket object. + * + * @see SocketFactory + */ + Socket.prototype.setSocketType = function(type, options) { + if ('string' !== typeof type) { + throw new TypeError('Socket.setSocketType: type must be string.'); + } + if (options && 'object' !== typeof options) { + throw new TypeError('Socket.setSocketType: options must be ' + + 'object or undefined.'); + } + this.socket = SocketFactory.get(this.node, type, options); + + if (!this.socket) { + throw new Error('Socket.setSocketType: type not found: ' + + type + '.'); + } + + this.type = type; + return this.socket; + }; + + /** + * ### Socket.connect + * + * Calls the connect method on the actual socket object + * + * Uri is usually empty when using SocketDirect. + * + * @param {string} uri Optional. The uri to which to connect. + * @param {object} options Optional. Configuration options for the socket. + */ + Socket.prototype.connect = function(uri, options) { + var humanReadableUri, that; + + if (this.isConnected()) { + throw new Error('Socket.connect: socket is already connected. ' + + 'Only one connection is allowed.'); + } + if (this.connecting) { + throw new Error('Socket.connecting: one connection attempt is ' + + 'already in progress. Please try again later.'); + } + if (uri && 'string' !== typeof uri) { + throw new TypeError('Socket.connect: uri must be string or ' + + 'undefined.'); + } + if (options) { + if ('object' !== typeof options) { + throw new TypeError('Socket.connect: options must be ' + + 'object or undefined.'); + } + this.userOptions = options; + } + + humanReadableUri = uri || 'local server'; + + if (!this.socket) { + throw new Error('Socket.connet: cannot connet to ' + + humanReadableUri + ' . No socket defined.'); + } + this.connecting = true; + this.url = uri; + this.node.info('connecting to ' + humanReadableUri + '.'); + this.node.emit('SOCKET_CONNECTING'); + this.socket.connect(this.url, this.userOptions); + + // Socket Direct might be already connected. + if (this.connected) return; + + that = this; + this.connectingTimeout = setTimeout(function() { + that.node.warn('connection attempt to ' + humanReadableUri + + ' timed out. Disconnected.'); + that.socket.disconnect(); + that.connecting = false; + }, this.connectingTimeoutMs); + }; + + /** + * ### Socket.reconnect + * + * Calls the connect method with previous parameters + * + * @param {boolean} force Optional. Forces the process to continue + * even if a previous reconnection is in progress. Warning: can + * cause an infinite loop. Default: FALSE + * + * @see Socket.connect + * @see Socket.disconnect + */ + Socket.prototype.reconnect = function(force) { + if (!this.url) { + throw new Error('Socket.reconnect: cannot find previous uri.'); + } + if (this.reconnecting && !force) { + node.warn('Socket.reconnect: socket is already reconnecting. ' + + 'Try with force parameter.'); + return; + } + this.reconnecting = true; + if (this.connecting || this.isConnected()) this.disconnect(); + this.connect(this.url, this.userOptions); + this.reconnecting = false; + }; + + /** + * ### Socket.disconnect + * + * Calls the disconnect method on the actual socket object + * + * @param {boolean} force Forces to call the underlying + * `socket.disconnect` method even if socket appears not connected + * nor connecting at the moment. + */ + Socket.prototype.disconnect = function(force) { + if (!force && (!this.connecting && !this.isConnected())) { + node.warn('Socket.disconnect: socket is not connected nor ' + + 'connecting. Try with force parameter.'); + return; + } + this.socket.disconnect(); + this.connecting = false; + this.connected = false; + }; + + /** + * ### Socket.onConnect + * + * Handler for connections to the server + * + * @emit SOCKET_CONNECT + */ + Socket.prototype.onConnect = function() { + this.connected = true; + this.connecting = false; + if (this.connectingTimeout) clearTimeout(this.connectingTimeout); + this.node.emit('SOCKET_CONNECT'); + + // The testing framework expects this, do not remove. + this.node.info('socket connected.'); + }; + + /** + * ### Socket.onDisconnect + * + * Handler for disconnections from the server + * + * Clears the player and monitor lists. + * + * @emit SOCKET_DISCONNECT + */ + Socket.prototype.onDisconnect = function() { + this.connected = false; + this.connecting = false; + this.node.emit('SOCKET_DISCONNECT'); + + // Save the current stage of the game + //this.node.session.store(); + + // On re-connection will receive a new ones. + this.node.game.pl.clear(); + this.node.game.ml.clear(); + + // Restore original message handler. + this.setMsgListener(this.onMessageHI); + + // Delete session. + this.session = null; + + this.node.info('socket closed.'); + }; + + /** + * ### Socket.secureParse + * + * Parses a string representing a game msg into a game msg object + * + * Checks that the id of the session is correct. + * + * @param {string} msg The msg string as received by the socket. + * @return {GameMsg|undefined} gameMsg The parsed msg, or + * undefined on error. + */ + Socket.prototype.secureParse = function(msg) { + var gameMsg; + try { + gameMsg = GameMsg.clone(JSON.parse(msg)); + this.node.info('R: ' + gameMsg); + } + catch(e) { + return logSecureParseError.call(this, 'malformed msg received', e); + } + return gameMsg; + }; + + /** + * ### Socket.validateIncomingMsg + * + * Checks whether an incoming message is valid. + * + * Checks that the id of the session is correct. + * + * @param {object} msg The msg object to check + * @return {GameMsg|undefined} gameMsg The parsed msg, or + * undefined on error. + */ + Socket.prototype.validateIncomingMsg = function(gameMsg) { + if (this.session && gameMsg.session !== this.session) { + return logSecureParseError.call(this, 'mismatched session in ' + + 'incoming message.'); + } + return gameMsg; + }; + + /** + * ### Socket.onMessageHI + * + * Initial handler for incoming messages from the server + * + * This handler will be replaced by the FULL handler, upon receiving + * a HI message from the server. + * + * This method starts the game session, by creating a player object + * with the data received by the server. + * + * @param {GameMsg} msg The game message received and parsed by a socket. + * + * @see Socket.validateIncomingMsg + * @see Socket.startSession + * @see Socket.onMessageFull + * @see node.createPlayer + */ + Socket.prototype.onMessageHI = function(msg) { + msg = this.validateIncomingMsg(msg); + if (!msg) return; + + // Parsing successful. + if (msg.target === 'HI') { + + // Check if connection was authorized. + if (msg.to === parent.constants.UNAUTH_PLAYER) { + this.node.warn('connection was not authorized.'); + if (msg.text === 'redirect') { + if ('undefined' !== typeof window) { + window.location = msg.data; + } + } + else { + this.disconnect(); + } + return; + } + + // Replace itself: will change onMessage to onMessageFull. + this.setMsgListener(); + + // This will emit PLAYER_CREATED + this.startSession(msg); + // Functions listening to these events can be executed before HI. + + this.node.emit('NODEGAME_READY'); + } + }; + + /** + * ### Socket.onMessageFull + * + * Full handler for incoming messages from the server + * + * All parsed messages are either emitted immediately or buffered, + * if the game is not ready, and the message priority is low.x + * + * @param {GameMsg} msg The game message received and parsed by a socket. + * + * @see Socket.validateIncomingMsg + * @see Socket.onMessage + * @see Game.isReady + */ + Socket.prototype.onMessageFull = function(msg) { + msg = this.validateIncomingMsg(msg); + if (!msg) return; + + // Message with high priority are executed immediately. + if (msg.priority > 0 || this.node.game.isReady()) { + this.node.emit(msg.toInEvent(), msg); + } + else { + this.node.silly('B: ' + msg); + this.buffer.push(msg); + } + }; + + /** + * ### Socket.onMessage + * + * Handler for incoming messages from the server + * + * @see Socket.onMessageHI + * @see Socket.onMessageFull + */ + Socket.prototype.onMessage = Socket.prototype.onMessageHI; + + /** + * ### Socket.setMsgListener + * + * Sets the onMessage listener + * + * @param msgHandler {function} Optional. Callback function which is + * called for every message in the buffer instead of the messages + * being emitted. + * Default: Socket.onMessageFull + * + * @see this.node.emit + * @see Socket.clearBuffer + */ + Socket.prototype.setMsgListener = function(msgHandler) { + if (msgHandler && 'function' !== typeof msgHandler) { + throw new TypeError('Socket.setMsgListener: msgHandler must be a ' + + 'function or undefined. Found: ' + msgHandler); + } + + this.onMessage = msgHandler || this.onMessageFull; + }; + + /** + * ### Socket.shouldClearBuffer + * + * Returns TRUE, if buffered messages can be emitted + * + * @see node.emit + * @see Socket.clearBuffer + * @see Game.isReady + */ + Socket.prototype.shouldClearBuffer = function() { + return this.node.game.isReady(); + }; + + /** + * ### Socket.clearBuffer + * + * Emits and removes all the events in the message buffer + * + * @param msgHandler {function} Optional. Callback function which is + * called for every message in the buffer instead of the messages + * being emitted. + * Default: Emit every buffered message. + * + * @see node.emit + * @see Socket.shouldClearBuffer + */ + Socket.prototype.clearBuffer = function(msgHandler) { + var nelem, msg, i; + var funcCtx, func; + + if (msgHandler) { + funcCtx = this.node.game; + func = msgHandler; + } + else { + funcCtx = this.node.events; + func = this.node.events.emit; + } + + nelem = this.buffer.length; + for (i = 0; i < nelem; i++) { + // Modify the buffer at every iteration, so that if an error + // occurs, already emitted messages are out of the way. + msg = this.buffer.shift(); + if (msg) { + func.call(funcCtx, msg.toInEvent(), msg); + this.node.silly('D: ' + msg); + } + } + }; + + /** + * ### Socket.eraseBuffer + * + * Removes all messages currently in the buffer + * + * This operation is not reversible + * + * @see Socket.clearBuffer + */ + Socket.prototype.eraseBuffer = function() { + this.buffer = []; + }; + + /** + * ### Socket.startSession + * + * Initializes a nodeGame session + * + * Creates a the player and saves it in node.player, and + * stores the session ids in the session object. + * + * If a game window reference is found, sets the `uriChannel` variable. + * + * @param {GameMsg} msg A game-msg + * @param {boolean} force If TRUE, a new session will be created even + * if an existing one is found. + * + * @see node.createPlayer + * @see Socket.registerServer + * @see GameWindow.setUriChannel + */ + Socket.prototype.startSession = function(msg, force) { + if (this.session && !force) { + throw new Error('Socket.startSession: session already existing. ' + + 'Use force parameter to overwrite it.'); + } + + // We need to first set the session, + // and then eventually stop an ongoing game. + this.session = msg.session; + if (this.node.game.isStoppable()) this.node.game.stop(); + + // Channel name and create player. + this.channelName = msg.data.channel.name; + this.node.createPlayer(msg.data.player); + + // Set anti-spoofing as requested by server. + if (msg.data.antiSpoofing) { + if (this.socket.enableAntiSpoofing) { + this.antiSpoofing = true; + this.socket.enableAntiSpoofing(true); + } + else { + this.node.log('Socket.startSession: server requested anti-' + + 'spoofing, but socket does not support it.'); + } + } + + // Notify GameWindow (if existing, and if not default channel). + if (this.node.window && !msg.data.channel.isDefault) { + this.node.window.setUriChannel(this.channelName); + } + }; + + /** + * ### Socket.isConnected + * + * Returns TRUE if socket connection is ready. + */ + Socket.prototype.isConnected = function() { + return this.socket && this.socket.isConnected(); + }; + + /** + * ### Socket.send + * + * Pushes a message into the socket + * + * The msg is actually received by the client itself as well. + * + * @param {GameMsg} msg The game message to send + * + * @return {boolean} TRUE on success + * + * @see GameMsg + * + * TODO: when trying to send a message and the socket is not connected + * the message is just discarded. Outgoing messages could be buffered + * and sent out whenever the connection is available again. + */ + Socket.prototype.send = function(msg) { + var outEvent; + + if (!msg.from || msg.from === this.node.UNDEFINED_PLAYER) { + this.node.err('Socket.send: sender id not initialized, ' + + 'message not sent'); + return false; + } + + if (!this.isConnected()) { + this.node.err('Socket.send: no open socket, ' + + 'message not sent'); + + // TODO: test this + // this.outBuffer.push(msg); + return false; + } + + // TODO: test this + // if (!this.node.game.isReady()) { + // this.outBuffer.push(msg); + // return false; + // } + + // Emit out event, if required. + if (this.emitOutMsg) { + outEvent = msg.toOutEvent(); + this.node.events.ee.game.emit(outEvent, msg); + this.node.events.ee.stage.emit(outEvent, msg); + this.node.events.ee.step.emit(outEvent, msg); + } + + this.socket.send(msg); + this.node.info('S: ' + msg); + + // TODO: check this. + // Experimental code. + if (this.journalOn) { + // Only Game messages are stored. + if (this.node.game.isReady()) this.journal.insert(msg); + } + // End experimental code. + + return true; + }; + + // Helper methods. + + function logSecureParseError(text, e) { + var error; + text = text || 'generic error while parsing a game message.'; + error = (e) ? text + ": " + e : text; + this.node.err('Socket.secureParse: ' + error); + return false; + } + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # SocketIo + * Copyright(c) 2018 Stefano Balietti + * MIT Licensed + * + * Remote communication through Socket.IO + * + * This file requires that the socket.io library is already loaded before + * nodeGame is loaded to work (see closure). + */ +(function(exports, node, io) { + + // io is undefined in Node.JS because + // module.parents.exports.io does not exist. + + // ## Global scope + + var J = node.JSUS; + + exports.SocketIo = SocketIo; + + /** + * ## SocketIo constructor + * + * Creates a new instance of SocketIo + * + * @param {NodeGameClient} node Reference to the node instance + */ + function SocketIo(node) { + + // ## Private properties + + /** + * ### SocketIo.node + * + * Reference to the node object. + */ + this.node = node; + + /** + * ### Socket.socket + * + * Reference to the actual socket-io socket created on connection + */ + this.socket = null; + } + + /** + * ### SocketIo.connect + * + * Establishes a socket-io connection with a server + * + * Sets the on: 'connect', 'message', 'disconnect' event listeners. + * + * @param {string} url The address of the server channel + * @param {object} options Optional. Configuration options + */ + SocketIo.prototype.connect = function(url, options) { + var node, socket; + node = this.node; + + if ('string' !== typeof url) { + throw TypeError('SocketIO.connect: url must be string.'); + } + + // See https://github.com/Automattic/socket.io-client/issues/251 + J.mixin(options, { 'force new connection': true }); + + socket = io.connect(url, options); //conf.io + + socket.on('connect', function() { + node.info('socket.io connection open'); + node.socket.onConnect.call(node.socket); + socket.on('message', function(msg) { + msg = node.socket.secureParse(msg); + if (msg) { + node.socket.onMessage(msg); + } + }); + }); + + socket.on('disconnect', function() { + node.socket.onDisconnect.call(node.socket); + }); + + this.socket = socket; + + return true; + }; + + /** + * ### SocketIo.disconnect + * + * Triggers the disconnection from a server + */ + SocketIo.prototype.disconnect = function() { + this.socket.disconnect(); + }; + + /** + * ### SocketIo.isConnected + * + * Returns TRUE, if currently connected + */ + SocketIo.prototype.isConnected = function() { + return this.socket && this.socket.connected; + }; + + /** + * ### SocketIo.sendEasy + * + * Stringifies and send a message through the socket-io socket + * + * @param {object} msg Object implementing a stringify method. Usually, + * a game message. + * + * @see GameMessage + */ + SocketIo.prototype.sendEasy = function(msg) { + this.socket.send(msg.stringify()); + }; + + /** + * ### SocketIo.sendNoSpoof + * + * Like SocketIo.sendEasy, but it adds the sid to the message. + * + * @param {object} msg Object implementing a stringify method. Usually, + * a game message. + * + * @see SocketIo.sendEasy + */ + SocketIo.prototype.sendNoSpoof = function(msg) { + // Add socket id to prevent spoofing. + msg.sid = this.node.player.strippedSid; + this.socket.send(msg.stringify()); + }; + + /** + * ### SocketIo.send + * + * Generic function to send a message through the socket-io socket + * + * @param {object} msg Object implementing a stringify method. Usually, + * a game message. + * + * @see GameMessage + * @see SocketIo.sendEasy + * @see SocketIo.sendNoSpoof + */ + SocketIo.prototype.send = SocketIo.prototype.sendEasy; + + /** + * ### SocketIo.enableAntiSpoofing + * + * Adds/removes no-spoof signature from messages + * + * @param {boolean} value TRUE to enable, FALSE to disable + * + * @see SocketIo.sendEasy + * @see SocketIo.sendNoSpoof + */ + SocketIo.prototype.enableAntiSpoofing = function(value) { + if (value) this.send = this.sendNoSpoof; + else this.send = this.sendEasy; + }; + + node.SocketFactory.register('SocketIo', SocketIo); + +})( + 'undefined' !== typeof node ? node : module.exports, + 'undefined' !== typeof node ? node : module.parent.exports, + 'undefined' !== typeof module && 'undefined' !== typeof require ? + require('socket.io-client') : 'undefined' !== typeof io ? io : {} +); + +/** + * # Roler + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * Handles assigning roles to matches. + * + * Currently only supports assigning roles to matches of size 2. + */ +(function(exports, parent) { + + "use strict"; + + // TODO: have x, y indexes like in Matcher? + + // ## Global scope + var J = parent.JSUS; + + exports.Roler = Roler; + + // ## Static methods. + + /** + * ### Roler.linearRolifier + * + * Applies roles to a single match + * + * This is the default callback copied over `Matcher.rolify`. + * + * @param {array} A match array containing two valid ids, or + * one id and a 'missing-id' + * + * @return {array} roles An array containing the roles for the match. + * Missing ids will receive an undefined role + * + * @see Roler.rolify + * @see Roler.setRolifyCb + */ + Roler.linearRolifier = function(match, x, y) { + var roles, len; + var id1, id2, soloIdx; + len = match.length; + if (!len) { + throw new Error('Roler.rolify: match must be a non empty array. ' + + 'Found: ' + match); + } + id1 = match[0]; + id2 = match[1]; + roles = new Array(len); + if (id1 !== this.missingId && id2 !== this.missingId) { + this.setRoleFor(id1, this.rolesArray[0], x); + this.setRoleFor(id2, this.rolesArray[1], x); + roles = [ this.rolesArray[0], this.rolesArray[1] ]; + } + else { + if (!this.rolesArray[2]) { + throw new Error('Roler.rolify: role3 required, but not found.'); + } + soloIdx = (id1 === this.missingId) ? 1 : 0; + this.setRoleFor(match[soloIdx], this.rolesArray[2], x); + roles[soloIdx] = this.rolesArray[2]; + } + return roles; + }; + + /** + * ## Roler constructor + * + * Creates a new instance of role mapper + */ + function Roler() { + + /** + * ### Roler.roles + * + * List of all available roles + * + * @see Roler.setRoles + */ + this.roles = {}; + + /** + * ### Roler.rolesArray + * + * The array of currently available roles + * + * @see Roler.setRoles + * @see Roler.clear + */ + this.rolesArray = []; + + /** + * ### Roler.rolifiedMatches + * + * Array of arrays of roles assigned for all matches in all rounds + * + * For example: + * ```javascript + * [ + * // Round 1. + * [ [ 'ROLE_A', 'ROLE_B' ], [ 'ROLE_A', 'ROLE_B' ], ... ], + * // Round 2. + * [ [ 'ROLE_A', 'ROLE_B' ], [ 'ROLE_A', 'ROLE_B' ], ... ], + * ... + * ] + * ``` + * + * @see Roler.rolifyAll + * @see Roler.setRolifiedMatches + */ + this.rolifiedMatches = null; + + /** + * ### Roler.role2IdMatches + * + * Array of arrays of maps role to id/s for all matches in all rounds + * + * For example: + * ```javascript + * [ + * [ { ROLE_A: 'ID1', ROLE_B: 'ID2' }, ... ], // Round 1. + * [ { ROLE_A: [ 'ID1', 'ID2' ] }, ... ], // Round 2. + * ... + * ] + * ``` + * + * @see Roler.rolifyAll + * @see Roler.setRolifiedMatches + */ + this.role2IdMatches = null; + + /** + * ### Roler.id2RoleMatches + * + * Array of arrays of maps id to role for all matches in all rounds + * + * For example: + * ```javascript + * [ + * [ { ID1: 'ROLE_A', ID2: 'ROLE_B' }, ... ], // Round 1. + * [ { ID1: 'ROLE_A', ID2: 'ROLE_A' }, ... ], // Round 2. + * ... + * ] + * ``` + * + * @see Roler.rolifyAll + * @see Roler.setRolifiedMatches + */ + this.id2RoleMatches = null; + + /** + * ### Roler.role2IdRoundMap + * + * Array of maps of role to id/s per each round + * + * For example: + * ```javascript + * [ + * // Round 1. + * [ { ROLE_A: [ 'ID1', 'ID3', ... ], ROLE_B: 'ID2', ... } ], + * // Round 2. + * [ { ROLE_A: [ 'ID1', 'ID2', ... ], ROLE_B: 'ID3', ... } ], + * ... + * ] + * ``` + */ + this.role2IdRoundMap = []; + + /** + * ### Roler.rolify + * + * Callback that assigns roles to a single match + * + * @see Roler.linearRolifier + */ + this.rolify = Roler.linearRolifier; + + /** + * ### Roler.missingId + * + * The id indicating a skipped match (i.e. a bye in a match) + * + * @see Matcher.missingId + */ + this.missingId = 'bot'; + } + + // ## Init/clear. + + /** + * ### Roler.init + * + * Inits the Roler instance + * + * @param {object} options + */ + Roler.prototype.init = function(options) { + options = options || {}; + if (options.rolifyCb) this.setRolifyCb(options.rolifyCb); + if (options.roles) this.setRoles(options.roles); + if (options.missingId) this.missingId = options.missingId; + }; + + /** + * ### Roler.clear + * + * Clears all roles lists + */ + Roler.prototype.clear = function() { + this.roles = {}; + this.rolesArray = []; + this.id2RoleRoundMap = []; + this.role2IdRoundMap = []; + this.rolifiedMatches = null; + this.role2IdMatches = null; + this.id2RoleMatches = null; + }; + + // ## Setters. + + /** + * ### Roler.setRolifyCb + * + * Sets the callback assigning the roles + * + * The callback takes as input a match array, and optionally its + * x and y coordinates in the array of matches. + * + * @param {function} cb The rolifier cb + * + * @see Roler.rolify + */ + Roler.prototype.setRolifyCb = function(cb) { + if ('function' !== typeof cb) { + throw new TypeError('Roler.setRolifyCb: cb must be function. ' + + 'Found: ' + cb); + } + this.rolify = cb; + }; + + /** + * ### Roler.setRoles + * + * Validates and sets the roles + * + * @param {array} roles Array of roles (string) + * @param {number} min At least _min_ roles must be specified. Default: 2 + * @param {number} max At least _max_ roles must be specified. Default: inf + * + * @see Roler.setRoles + * @see Roler.clear + */ + Roler.prototype.setRoles = function(roles, min, max) { + var rolesObj, role; + var i, len; + var err; + + // Clear previousd data. + this.clear(); + + if (min && 'number' !== typeof min || min < 2) { + throw new TypeError('Roler.setRoles: min must be a ' + + 'number > 2 or undefined. Found: ' + min); + } + min = min || 2; + + if (max && 'number' !== typeof max || max < min) { + throw new TypeError('Roler.setRoles: max must ' + + 'be number or undefined. Found: ' + max); + } + + // At least two roles must be defined + if (!J.isArray(roles)) { + throw new TypeError('Roler.setRoles: roles must ' + + 'be array. Found: ' + roles); + } + + len = roles.length; + // At least two roles must be defined + if (len < min || len > max) { + err = 'Roler.setRoles: roles must contain at least ' + + min + ' roles'; + if (max) err += ' and no more than ' + max; + err += '. Found: ' + len; + throw new Error(err); + } + + rolesObj = {}; + i = -1; + for ( ; ++i < len ; ) { + role = roles[i]; + if ('string' !== typeof role || role.trim() === '') { + throw new TypeError('Roler.setRoles: each role ' + + 'must be a non-empty string. Found: ' + + role); + } + rolesObj[role] = true; + } + // All data validated. + this.roles = rolesObj; + this.rolesArray = roles; + }; + + /** + * ### Roler.setRoleFor + * + * Sets a role for the given id at the specified round + * + * @param {string} id The id of a player + * @param {string} role A valid role for the id + * @param {number} x The x-th round the role is being set for + * + * @see Roler.id2RoleRoundMap + * @see Roler.role2IdRoundMap + */ + Roler.prototype.setRoleFor = function(id, role, x) { + if ('string' !== typeof id) { + throw new TypeError('Roler.setRoleFor: id must be string. Found: ' + + id); + } + if ('string' !== typeof role) { + throw new TypeError('Roler.setRoleFor: role must be string. ' + + 'Found: ' + role); + } + if (!this.roles[role]) { + throw new Error('Roler.setRoleFor: unknown role: ' + role); + } + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.setRoleFor: x must be a non-negative ' + + 'number. Found: ' + x); + } + // Id to role. + if (!this.id2RoleRoundMap[x]) this.id2RoleRoundMap[x] = {}; + this.id2RoleRoundMap[x][id] = role; + + // Role to id. + if (!this.role2IdRoundMap[x]) this.role2IdRoundMap[x] = {}; + if (!this.role2IdRoundMap[x][role]) this.role2IdRoundMap[x][role] = []; + this.role2IdRoundMap[x][role].push(id); + }; + + /** + * ### Roler.setRolifiedMatches + * + * Sets a preinited array of rolified matches + * + * @param {array} rolifiedMatches The rolified matches + * @param {boolean} validate Optional. Boolean flag to + * turn on/off validation. Default: TRUE + * + * @see Roler.rolifiedMatches + */ + Roler.prototype.setRolifiedMatches = function(rolifiedMatches, validate) { + var i, len; + var j, lenJ; + if ('undefined' === typeof validate || !!validate) { + if (!J.isArray(rolifiedMatches) || !rolifiedMatches.length) { + throw new Error('Roler.setRolifiedMatches: rolifiedMatches ' + + 'must be a non-empty array. Found: ' + + rolifiedMatches); + } + i = -1, len = rolifiedMatches.length; + for ( ; ++i < len ; ) { + i = -1, lenJ = rolifiedMatches[i].length; + if (!lenJ) { + throw new Error('Roler.setRolifiedMatches: ' + + 'rolifiedMatches round ' + i + + 'has no elements.'); + } + for ( ; ++i < lenJ ; ) { + if (!J.isArray(rolifiedMatches[i][j])) { + throw new Error('Roler.setRolifiedMatches: ' + + 'rolifiedMatches round ' + i + + ' element ' + j + + ' should be array. Found: ' + + rolifiedMatches[i][j]); + } + // These are specific to the rolify cb. + if (rolifiedMatches[i][j].length !== 2) { + throw new Error('Roler.setRolifiedMatches: roles (' + + i + ',' + j + ') was expected to have' + + ' length 2: ' + rolifiedMatches[i][j]); + } + if ('string' !== typeof rolifiedMatches[i][j][0] || + 'string' !== typeof rolifiedMatches[i][j][1] || + rolifiedMatches[i][j][0].trim() === '' || + rolifiedMatches[i][j][1].trim() === '') { + + throw new Error('Roler.setRolifiedMatches: roles (' + + i + ',' + j + ') has invalid ' + + 'elements: ' + rolifiedMatches[i][j]); + } + } + + } + } + this.rolifiedMatches = rolifiedMatches; + }; + + /** + * ### Roler.setRole2IdMatches + * + * Sets a preinited array of role to id/s matches + * + * @param {array} matches The role to id/s matches + * @param {boolean} validate Optional. Boolean flag to + * turn on/off validation. Default: TRUE + * + * @see Roler.rolifiedMatches + */ + Roler.prototype.setRole2IdMatches = function(matches, validate) { + if ('undefined' === typeof validate || !!validate) { + validateRoleIdMatches('setRole2IdMatches', matches); + } + this.role2IdMatches = matches; + }; + + /** + * ### Roler.setId2RoleMatches + * + * Sets a preinited array of id to role matches + * + * @param {array} matches The roles-obj matches + * @param {boolean} validate Optional. Boolean flag to + * turn on/off validation. Default: TRUE + * + * @see Roler.id2RoleMatches + */ + Roler.prototype.setId2RoleMatches = function(matches, validate) { + if ('undefined' === typeof validate || !!validate) { + validateRoleIdMatches('setId2RoleMatches', matches); + } + this.id2RoleMatches = matches; + }; + + // ## Getters. + + /** + * ### Roler.getRoleFor + * + * Returns the role hold by an id at round x + * + * @param {string} id The id to check + * @param {number} x The round to check + * + * @return {string|null} The role currently hold, or null + * if the id is not found + * + * @see Roler.id2RoleRoundMap + */ + Roler.prototype.getRoleFor = function(id, x) { + if ('string' !== typeof id) { + throw new TypeError('Roler.getRoleFor: id must be string. Found: ' + + id); + } + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.getRoleFor: x must be a non-negative ' + + 'number. Found: ' + x); + } + return this.id2RoleRoundMap[x][id] || null; + }; + + /** + * ### Roler.getIdForRole + * + * Returns the id/s holding the specified role at round x + * + * @param {string} role The role + * @param {number} x The round + * + * @return {array} Array of id/s holding the role at round x + * + * @see Roler.role2IdRoundMap + */ + Roler.prototype.getIdForRole = function(role, x) { + if ('string' !== typeof role) { + throw new TypeError('Roler.getIdForRole: role must be string. ' + + 'Found: ' + role); + } + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.getIdForRole: x must be a ' + + 'non-negative number. Found: ' + x); + } + return this.role2IdRoundMap[x][role] || []; + }; + + /** + * ### Roler.getRolifiedMatches + * + * Returns all matched roles + * + * @return {array} The matched roles + * + * @see Roler.rolifiedMatches + */ + Roler.prototype.getRolifiedMatches = function() { + return this.rolifiedMatches; + }; + + /** + * ### Roler.getRoleMatch + * + * Returns the requested roles + * + * @param {number} x The round of the roles + * @param {number} y Optional. The y-th role within round x + * + * @return {array|null} The requested role matches or null + * if the x or y indexes are out of bounds + * + * @see Roler.rolifiedMatches + */ + Roler.prototype.getRoleMatch = function(x, y) { + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.getRoleMatch: x must be a ' + + 'non-negative number. Found: ' + x); + } + if ('undefined' === typeof y) { + return this.rolifiedMatches[x] || null; + } + if ('number' !== typeof y || y < 0 || isNaN(y)) { + throw new TypeError('Roler.getRoleMatch: y must be undefined or ' + + 'a non-negative number. Found: ' + y); + } + return this.rolifiedMatches[x][y] || null; + }; + + /** + * ### Roler.getRole2IdMatch + * + * Returns the requested role to id matches + * + * @param {number} x The round of the roles + * @param {number} y Optional. The y-th role within round x + * + * @return {array|object|null} The role to id/s matches or null + * if the x or y indexes are out of bounds + * + * @see Roler.role2IdRoundMatch + */ + Roler.prototype.getRole2IdMatch = function(x, y) { + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.getRole2IdMatch: x must be a ' + + 'non-negative number. Found: ' + x); + } + if ('undefined' === typeof y) { + return this.role2IdMatches[x] || null; + } + if ('number' !== typeof y || y < 0 || isNaN(y)) { + throw new TypeError('Roler.getRole2IdMatch: y must be a ' + + 'non-negative number. Found: ' + y); + } + return this.role2IdMatches[x][y] || null; + }; + + /** + * ### Roler.getId2RoleMatch + * + * Returns the requested id to role matches + * + * @param {number} x The round of the roles + * @param {number} y Optional. The y-th role within round x + * + * @return {array|object|null} The id to role matches or null + * if the x or y indexes are out of bounds + * + * @see Roler.rolifiedMatches + */ + Roler.prototype.getId2RoleMatch = function(x, y) { + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.getId2RoleMatch: x must be a ' + + 'non-negative number. Found: ' + x); + } + if ('undefined' === typeof y) { + return this.id2RoleMatches[x] || null; + } + if ('number' !== typeof y || y < 0 || isNaN(y)) { + throw new TypeError('Roler.getId2RoleMatch: y must be a ' + + 'non-negative number. Found: ' + y); + } + return this.id2RoleMatches[x][y] || null; + }; + + /** + * ### Roler.getRole2IdRoundMap + * + * Returns the requested role to id/s mapping + * + * @param {number} x The round + * + * @return {object|null} The role to id/s map or null + * if x is out of bounds + * + * @see Roler.role2IdRoundMap + */ + Roler.prototype.getRole2IdRoundMap = function(x) { + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.getRole2IdRoundMap: x must be a ' + + 'non-negative number. Found: ' + x); + } + return this.role2IdRoundMap[x] || null; + }; + + /** + * ### Roler.getId2RoleRoundMap + * + * Returns the requested id to role mapping + * + * @param {number} x The round + * + * @return {object|null} The role-to-id map or null + * if x is out of bounds + * + * @see Roler.id2RoleRoundMap + */ + Roler.prototype.getId2RoleRoundMap = function(x) { + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.getId2RoleRoundMap: x must be a ' + + 'non-negative number. Found: ' + x); + } + return this.id2RoleRoundMap[x] || null; + }; + + // ## Rolify. + + /** + * ### Roler.rolifyAll + * + * Applies roles to all matches + * + * @param {array} Array of array of matches + * + * @return {array} rolifiedMatches The full maps of roles + * + * @see Roler.rolifiedMatches + * @see Roler.role2IdMatches + * @see Roler.id2RoleMatches + */ + Roler.prototype.rolifyAll = function(matches) { + var i, len, j, lenJ, row, rolifiedMatches; + var r1, r2, rolesObj, idRolesObj; + + if (!J.isArray(matches) || !matches.length) { + throw new Error('Roler.rolifyAll: match must be a non empty ' + + 'array. Found: ' + matches); + } + i = -1, len = matches.length; + rolifiedMatches = new Array(len); + rolesObj = new Array(len); + idRolesObj = new Array(len); + for ( ; ++i < len ; ) { + row = matches[i]; + j = -1, lenJ = row.length; + rolifiedMatches[i] = new Array(lenJ); + rolesObj[i] = new Array(lenJ); + idRolesObj[i] = new Array(lenJ); + for ( ; ++j < lenJ ; ) { + rolifiedMatches[i][j] = this.rolify(row[j], i, j); + // TODO: this code is repeated in Matcher.match, make it one! + r1 = rolifiedMatches[i][j][0]; + r2 = rolifiedMatches[i][j][1]; + rolesObj[i][j] = {}; + if (r1 !== r2) { + rolesObj[i][j][r1] = row[j][0]; + rolesObj[i][j][r2] = row[j][1]; + } + else { + rolesObj[i][j][r1] = [ row[j][0], row[j][1] ]; + } + idRolesObj[i][j] = {}; + idRolesObj[i][j][row[j][0]] = r1; + idRolesObj[i][j][row[j][1]] = r2; + } + } + this.rolifiedMatches = rolifiedMatches; + this.role2IdMatches = rolesObj; + this.id2RoleMatches = idRolesObj; + + return rolifiedMatches; + }; + + // ## Checkings. + + /** + * ### Roler.roleExists + * + * Returns TRUE if the requested role exists + * + * @param {string} role The role to check + * + * @see Roler.roles + */ + Roler.prototype.roleExists = function(role) { + if ('string' !== typeof role || role.trim() === '') { + throw new TypeError('Roler.roleExists: role must be ' + + 'a non-empty string. Found: ' + role); + } + return !!this.roles[role]; + }; + + /** + * ### Roler.hasRole + * + * Returns TRUE if a given id is holding the specified role at round x + * + * @param {string} id The id to check + * @param {string} role The role to check + * @param {number} x The round to check + * + * @return {boolean} True if id has given role + * + * @see Roler.id2RoleRoundMap + */ + Roler.prototype.hasRole = function(id, role, x) { + if ('string' !== typeof id) { + throw new TypeError('Roler.hasRole: id must be string. Found: ' + + id); + } + if ('string' !== typeof role) { + throw new TypeError('Roler.hasRole: role must be string. Found: ' + + role); + } + if ('number' !== typeof x || x < 0 || isNaN(x)) { + throw new TypeError('Roler.hasRole: x must be a non-negative ' + + 'number. Found: ' + x); + } + return this.id2RoleRoundMap[x][id] === role; + }; + + // ## Edit/Replace. + + /** + * ### Roler.replaceId + * + * Replaces an id with a new one in all roles + * + * @param {string} oldId The id to be replaced + * @param {string} newId The replacing id + * + * @return {boolean} TRUE, if the oldId was found and replaced + * + * @see MatcherManager.replaceId + * @see Matcher.replaceId + */ + Roler.prototype.replaceId = function(oldId, newId) { + var m, n; + var i, len, j, lenJ, h, lenH, k, lenK; + var rowFound; + var tmp, role; + + if ('string' !== typeof oldId) { + throw new TypeError('Roler.replaceId: oldId should be string. ' + + 'Found: ' + oldId); + } + if ('string' !== typeof newId && newId.trim() !== '') { + throw new TypeError('Roler.replaceId: newId should be a ' + + 'non-empty string. Found: ' + newId); + } + + // No id was assigned yet. + if (!this.id2RoleMatches) return false; + + // Update id2RoleMatches and role2IdMatches at the same time. + m = this.id2RoleMatches; + n = this.role2IdMatches; + + i = -1, len = m.length; + for ( ; ++i < len ; ) { + j = -1, lenJ = m[i].length; + // If it was not found in the previous row, return FALSE. + if (j > 0 && !rowFound) return false; + for ( ; ++j < lenJ ; ) { + rowFound = false; + for (h in m[i][j]) { + if (m[i][j].hasOwnProperty(h)) { + if (h === oldId) { + role = m[i][j][oldId]; + m[i][j][newId] = role; + delete m[i][j][oldId]; + rowFound = true; + + // All ids in match with same role. + tmp = n[i][j][role]; + + // If it is an array, try to optimize replacement. + if (J.isArray(tmp)) { + lenK = tmp.length; + if (lenK === 1) { + tmp[0] = newId; + } + else if (lenK === 2) { + if (tmp[0] === oldId) tmp[0] = newId; + else tmp[1] = newId; + } + else { + k = -1; + for ( ; ++k < lenK ; ) { + if (tmp[k] === oldId) { + tmp[k] = newId; + break; + } + } + } + } + else { + n[i][j][role] = newId; + } + + break; + } + } + } + if (rowFound) break; + } + } + + // Update id2RoleRoundMap and role2IdRoundMap at the same time. + m = this.id2RoleRoundMap; + n = this.role2IdRoundMap; + + i = -1, len = m.length; + for ( ; ++i < len ; ) { + rowFound = false; + for (j in m[i]) { + if (m[i].hasOwnProperty(j)) { + if (j === oldId) { + m[i][newId] = m[i][oldId]; + delete m[i][oldId]; + rowFound = true; + + // All ids with same role at same round. + tmp = n[i][m[i][newId]]; + + lenH = tmp.length; + if (lenH === 1) { + tmp[0] = newId; + } + else { + h = -1; + for ( ; ++h < len ; ) { + if (tmp[h] === oldId) { + tmp[h] = newId; + break; + } + } + } + + } + } + if (rowFound) break; + } + } + + return true; + }; + + // ## Helper methods. + + /** + * ### validateRoleIdMatches + * + * Deep validates role-id or id-role matches (object type), throws errors + * + * Validation: + * + * - The map is an array of objects + * - Each object must have two info-items. + * - An info-item can contain: + * a) 2 keys-valus pairs (id-role|role-id), or + * b) an array of length 2 (role: id1, id2) [allowed only if + * invoking method is 'setRole2IdMatches'] + * + * @param {string} method The name of the method invoking validation + * @param {array} matches The matches to validate + * + * @see validString + * @see setRole2IdMatches + * @see setId2RoleMatches + */ + function validateRoleIdMatches(method, matches) { + var i, len; + var j, lenJ; + var k, nKeys; + var arrayOk, elem, isArray; + + if (!J.isArray(matches) || !matches.length) { + throw new Error('Roler.' + method + ': matches must be a ' + + 'non-empty array. Found: ' + matches); + } + arrayOk = (method === 'setRole2IdMatches') ? true : false; + i = -1, len = matches.length; + for ( ; ++i < len ; ) { + i = -1, lenJ = matches[i].length; + if (!lenJ) { + throw new Error('Roler.' + method + ': matches round ' + + i + 'has no elements.'); + } + for ( ; ++i < lenJ ; ) { + if ('object' !== typeof matches[i][j]) { + throw new Error('Roler.' + method + ': matches ' + + 'round ' + i + ' element ' + j + + ' should be object. Found: ' + + matches[i][j]); + } + + nKeys = 0; + isArray = false; + for (k in matches[i][j]) { + if (matches[i][j].hasOwnProperty(k)) { + nKeys++; + if (k.trim() === '') { + throw new Error('Roler.' + method + ': ' + + 'roles matches (' + i + ',' + j + + ') has invalid key: ' + k); + } + elem = matches[i][j][k]; + if (arrayOk && J.isArray(elem)) { + isArray = true; + if (elem.length !== 2) { + throw new Error('Roler.' + method + ': ' + + 'roles matches (' + i + ',' + + j + ', ' + k + ') has ' + + 'invalid length: ' + elem); + } + validString(method, elem[0], i, j, k); + validString(method, elem[1], i, j, k); + } + else { + validString(method, elem, i, j, k); + } + } + } + // These are specific to the rolify cb. + if ((isArray && nKeys !== 1) || (!isArray && nKeys !== 2)) { + throw new Error('Roler.' + method + ': roles matches (' + + i + ',' + j + ') was expected to have ' + + '2 elements in total. Found: ' + + matches[i][j]); + } + } + } + } + + /** + * ### validString + * + * Validates the content of role/id or id/role match, throws errors + * + * @param {string} method The name of the method invoking validation + * @param {mixed} elem The element to validate (should + * be non-empty string: id or role) + * @param {number} i The i-th round in the matches array + * @param {number} j The j-th match at round i-th in the matches array + * @param {string} k The name of the key containig mapping to elem + * + * @see validString + */ + function validString(method, elem, i, j, k) { + if ('string' !== typeof elem || elem.trim() === '') { + throw new Error('Roler.' + method + ': roles map (' + i + ',' + j + + ',' + k +') has invalid elements: ' + elem); + } + } + + // ## Closure +})( + 'undefined' !== typeof node ? node : module.exports, + 'undefined' !== typeof node ? node : module.parent.exports +); + +/** + * # Matcher + * Copyright(c) 2020 Stefano Balietti + * MIT Licensed + * + * Class handling the creation of tournament schedules. + * + * http://www.nodegame.org + * --- + */ +(function(exports, node) { + + var J = node.JSUS; + var Roler = node.Roler; + + // Object containing methods to fetch a match in the requested format. + // Will be initialized later. + var fetchMatch; + + exports.Matcher = Matcher; + + // ## Static methods. + + /** + * ### Matcher.bye + * + * Symbol used to complete matching when partner is missing + * + * @see Matcher.matches + + */ + Matcher.bye = -1; + + /** + * ### Matcher.missingId + * + * Symbol assigned to matching number without valid id + * + * @see Matcher.resolvedMatches + * @see Roler.missingId + */ + Matcher.missingId = 'bot'; + + /** + * ## Matcher.randomAssigner + * + * Assigns ids to positions randomly. + * + * @param {array} ids The ids to assign + * + * @return The sorted array + * + * @see JSUS.shuffle + */ + Matcher.randomAssigner = function(ids) { + return J.shuffle(ids); + }; + + /** + * ### Matcher.linearAssigner + * + * Assigns ids to positions linearly. + * + * @param {array} ids The ids to assign + * + * @return The sorted array + */ + Matcher.linearAssigner = function(ids) { + return J.clone(ids); + }; + + /** + * ## Matcher constructor + * + * Creates a new Matcher object + * + * @param {object} options Optional. Configuration options + */ + function Matcher(options) { + + options = options || {}; + + /** + * ### Matcher.x + * + * The row-index of the last returned match by Matcher.getMatch + * + * @see Matcher.getMatch + */ + this.x = null; + + /** + * ### Matcher.y + * + * The column-index of the last returned match by Matcher.getMatch + * + * @see Matcher.getMatch + */ + this.y = null; + + /** + * ### Matcher.matches + * + * Nested array of matches (with position-numbers) + * + * Nests a new array for each round, and within each round + * individual matches are also array. For example: + * + * ```javascript + * + * // Matching array. + * [ + * + * // First round. + * [ [ p1, p2 ], [ p3, p4 ], ... ], + * + * // Second round. + * [ [ p2, p3 ], [ p4, p1 ], ... ], + * + * // Further rounds. + * ]; + * ``` + * + * @see Matcher.setMatches + */ + this.matches = null; + + /** + * ### Matcher.resolvedMatches + * + * Nested array of matches (with id-strings) + * + * Exactly Matcher.matches, but with with ids instead of numbers + * + * This method is used both by getMatch and getMatchObject (if + * a single match is requested). + * + * @see Matcher.matches + * @see Matcher.resolvedMatchesObj + * @see Matcher.resolvedMatchesById + * @see Matcher.setIds + * @see Matcher.setAssignerCb + * @see Matcher.match + */ + this.resolvedMatches = null; + + /** + * ### Matcher.resolvedMatchesObj + * + * Array of maps id to partner, one map per round + * + * ```javascript + * + * // Matching array. + * [ + * + * // First round. + * { p1: 'p2', p2: 'p1', p3: 'p4', p4: 'p3', ... }, + * + * // Second round. + * { p2: 'p3', p3: 'p2', p4: 'p1', p1: 'p4', ... }, + * + * // Further rounds. + * ]; + * ``` + * + * @see Matcher.resolvedMatches + * @see Matcher.resolvedMatchesById + * @see Matcher.setIds + * @see Matcher.match + */ + this.resolvedMatchesObj = null; + + /** + * ### Matcher.resolvedMatchesById + * + * Maps ids to a sequence of matches + * + * ```javascript + * + * // Matching object. + * { + * + * // All rounds. + * p1: [ 'p2', 'p4', ... ], + * p2: [ 'p1', 'p3', ... ], + * p3: [ 'p4', 'p2', ... ], + * p4: [ 'p3', 'p1', ... ] + * ... + * + * }; + * ``` + * + * @see Matcher.resolvedMatches + * @see Matcher.resolvedMatchesObj + * @see Matcher.setIds + * @see Matcher.match + */ + this.resolvedMatchesById = null; + + /** + * ### Matcher.ids + * + * Array ids to match + * + * @see Matcher.setIds + */ + this.ids = null; + + /** + * ### Matcher.ids + * + * Array mapping each ordinal position to an id + * + * @see Matcher.ids + * @see Matcher.assignerCb + */ + this.assignedIds = null; + + /** + * ### Matcher.idsMap + * + * Map ids to match + * + * @see Matcher.setIds + */ + this.idsMap = null; + + /** + * ### Matcher.assignedIdsMap + * + * Map ids to ordinal position in matches + * + * @see Matcher.idsMap + * @see Matcher.assignedIds + */ + this.assignedIdsMap = null; + + /** + * ### Matcher.assignerCb + * + * Callback that assigns ids to positions + * + * An assigner callback must take as input an array of ids, + * reorder them according to some criteria, and return it. + * The order of the items in the returned array will be used to + * match the numbers in the `matches` array. + * + * @see Matcher.ids + * @see Matcher.matches + * @see Matcher.assignedIds + */ + this.assignerCb = Matcher.randomAssigner; + + /** + * ## Matcher.missingId + * + * An id used to replace missing players ids + */ + this.missingId = Matcher.missingId; + + /** + * ## Matcher.missingId + * + * An id used by matching algorithms to complete unfinished matches + */ + this.bye = Matcher.bye; + + /** + * ## Matcher.doObjLists + * + * Flag that obj lists should be created when `match` is invoked + * + * @see Matcher.resolvedMatchesObj + * @see Matcher.matcher + */ + this.doObjLists = true; + + /** + * ## Matcher.doIdLists + * + * Flag that id lists should be created when `match` is invoked + * + * @see Matcher.resolvedMatchesById + * @see Matcher.matcher + */ + this.doIdLists = true; + + /** + * ## Matcher.doRoles + * + * Flag that roles should be assigned when `match` is invoked + * + * Requires roles to be set, otherwise an error is thrown + * + * @see Matcher.roles + * @see Matcher.roler + * @see Matcher.matcher + */ + this.doRoles = false; + + /** + * ## Matcher.roler + * + * Handles assigning roles to matches + * + * If null here, is initialized by `init` if doRoles is TRUE. + * + * @see Matcher.doRoles + * @see Matcher.init + */ + this.roler = options.roler || null; + + /** + * ## Matcher.roles + * + * Roles map created if `doRoles` is TRUE + * + * @see Matcher.doRoles + * @see Matcher.roler + * @see Matcher.matcher + */ + this.roler = options.roler || null; + + // Init. + this.init(options); + } + + /** + * ### Matcher.init + * + * Inits the Matcher instance + * + * @param {object} options + */ + Matcher.prototype.init = function(options) { + options = options || {}; + + if (options.assignerCb) this.setAssignerCb(options.assignerCb); + if (options.ids) this.setIds(options.ids); + if (options.bye) this.bye = options.bye; + if (options.missingId) this.missingId = options.missingId; + + if (null === options.x) this.x = null; + else if ('number' === typeof options.x) { + if (options.x < 0) { + throw new Error('Matcher.init: options.x cannot be negative.' + + 'Found: ' + options.x); + } + this.x = options.x; + } + else if (options.x) { + throw new TypeError('Matcher.init: options.x must be number, ' + + 'null or undefined. Found: ' + options.x); + } + + if (null === options.y) this.y = null; + else if ('number' === typeof options.y) { + if (options.y < 0) { + throw new Error('Matcher.init: options.y cannot be negative.' + + 'Found: ' + options.y); + } + this.y = options.y; + } + else if (options.y) { + throw new TypeError('Matcher.init: options.y must be number, ' + + 'null or undefined. Found: ' + options.y); + } + + if (options.doRoles || options.roles) { + if (!this.roler) this.roler = new Roler(); + this.roler.init({ + missingId: this.missingId, + roles: options.roles + }); + this.doRoles = true; + } + else if ('undefined' !== typeof options.doRoles) { + this.doRoles = !!options.doRoles; + } + + if ('undefined' !== typeof options.doObjLists) { + this.doObjLists = !!options.doObjLists; + } + + if ('undefined' !== typeof options.doIdLists) { + this.doIdLists = !!options.doIdLists; + } + }; + + /** + * ### Matcher.generateMatches + * + * Creates a matches array according to the chosen scheduling algorithm + * + * Throws an error if the selected algorithm is not found. + * + * @param {string} alg The chosen algorithm. Available: 'roundrobin', + * 'random' + * + * @return {array} The array of matches + */ + Matcher.prototype.generateMatches = function(alg) { + var matches; + if ('string' !== typeof alg) { + throw new TypeError('Matcher.generateMatches: alg must be ' + + 'string. Found: ' + alg); + } + alg = alg.toLowerCase(); + if (alg === 'roundrobin' || alg === 'round_robin' || + alg === 'random' || alg === 'random_pairs' ) { + + matches = pairMatcher(alg, arguments[1], arguments[2]); + } + else { + throw new Error('Matcher.generateMatches: unknown algorithm: ' + + alg); + } + + this.setMatches(matches); + return matches; + }; + + /** + * ### Matcher.setMatches + * + * Sets the matches for current instance + * + * Resets resolvedMatches and resolvedMatchesObj to null. + * + * @param {array} The array of matches + * + * @see this.matches + */ + Matcher.prototype.setMatches = function(matches) { + if (!J.isArray(matches) || !matches.length) { + throw new TypeError('Matcher.setMatches: matches must be a ' + + 'non-empty array. Found: ' + matches); + } + this.matches = matches; + resetResolvedData(this); + }; + + /** + * ### Matcher.getMatches + * + * Returns the matches for current instance + * + * @return {array|null} The array of matches (NULL if not yet set) + * + * @see this.matches + */ + Matcher.prototype.getMatches = function() { + return this.matches; + }; + + /** + * ### Matcher.setIds + * + * Sets the ids to be used for the matches + * + * @param {array} ids Array containing the id of the matches + * + * @see Matcher.ids + * @see Matcher.idsMap + */ + Matcher.prototype.setIds = function(ids) { + var i, len; + if (!J.isArray(ids) || !ids.length) { + throw new TypeError('Matcher.setIds: ids must be a non-empty ' + + 'array. Found: ' + ids); + } + // Keep track of all ids. + this.idsMap = {}; + i = -1, len = ids.length; + for ( ; ++i < len ; ) { + // TODO: validate? Duplicated ids are fine? + this.idsMap[ids[i]] = true; + } + this.ids = ids; + resetResolvedData(this); + }; + + /** + * ### Matcher.getIds + * + * Returns the ids used to created the matching + * + * @return {array} ids Ids in use + * + * @see Matcher.ids + */ + Matcher.prototype.getIds = function() { + return this.ids; + }; + + /** + * ### Matcher.assignIds + * + * Calls the assigner callback to assign ids to positions + * + * Ids can be overwritten by parameter. If no ids are found, + * they will be automatically generated, provided that matches + * have been generated first. + * + * @param {array} ids Optional. Array containing the id of the matches + * to pass to Matcher.setIds + * + * @see Matcher.ids + * @see Matcher.setIds + * @see Matcher.assignedIds + * @see Matcher.assignedIdsMap + */ + Matcher.prototype.assignIds = function(ids) { + var i, len; + if ('undefined' !== typeof ids) this.setIds(ids); + if (!J.isArray(this.ids) || !this.ids.length) { + if (!J.isArray(this.matches) || !this.matches.length) { + throw new TypeError('Matcher.assignIds: no ids and no ' + + 'matches found.'); + } + this.ids = J.seq(0, this.matches.length -1, 1, function(i) { + return '' + i; + }); + } + this.assignedIds = this.assignerCb(this.ids); + // Map all ids to its position. + this.assignedIdsMap = {}; + i = -1, len = this.assignedIds.length; + for ( ; ++i < len ; ) { + this.assignedIdsMap[this.assignedIds[i]] = i; + } + }; + + /** + * ### Matcher.setAssignerCb + * + * Specify a callback to be used to assign existing ids to positions + * + * @param {function} cb The assigner cb + * + * @see Matcher.ids + * @see Matcher.matches + * @see Matcher.assignerCb + */ + Matcher.prototype.setAssignerCb = function(cb) { + if ('function' !== typeof cb) { + throw new TypeError('Matcher.setAssignerCb: cb must be ' + + 'function. Found: ' + cb); + } + this.assignerCb = cb; + }; + + /** + * ### Matcher.match + * + * Substitutes the ids to the matches + * + * Populates the indexes: + * + * - `resolvedMatches`, + * - `resolvedMatchesObj`, + * - `resolvedMatchesById` + * + * If the matches array is not already set, an error is thrown. + * + * If the ids have not been assigned, it does automatic assignment. + * + * @param {boolean|array} assignIds Optional. A flag to force to + * re-assign existing ids, or an an array containing new ids to + * assign. + * + * @see Matcher.assignIds + * @see Matcher.resolvedMatchesObj + * @see Matcher.resolvedMatches + * + * TODO: creates two lists of matches with bots and without. + */ + Matcher.prototype.match = function(assignIds) { + var i, lenI, j, lenJ, pair; + var matched, matchedObj, matchedId, id1, id2; + var roles, rolesObj, idRolesObj, r1, r2; + + if (!J.isArray(this.matches) || !this.matches.length) { + throw new Error('Matcher.match: no matches found'); + } + + // Assign/generate ids if not done before. + if (!this.assignedIds || assignIds) { + if (J.isArray(assignIds)) this.assignIds(assignIds); + else this.assignIds(); + } + + // Parse the matches array and creates two data structures + // where the absolute position becomes the player id. + i = -1, lenI = this.matches.length; + matched = new Array(lenI); + matchedObj = this.doObjLists ? new Array(lenI) : null; + matchedId = this.doIdLists ? {} : null; + if (this.doRoles) { + roles = new Array(lenI); + rolesObj = new Array(lenI); + idRolesObj = new Array(lenI); + } + else { + roles = null; + rolesObj = null; + idRolesObj = null; + } + for ( ; ++i < lenI ; ) { + j = -1, lenJ = this.matches[i].length; + matched[i] = new Array(lenJ); + if (this.doObjLists) matchedObj[i] = {}; + if (this.doRoles) { + roles[i] = new Array(lenJ); + rolesObj[i] = new Array(lenJ); + idRolesObj[i] = new Array(lenJ); + } + for ( ; ++j < lenJ ; ) { + id1 = null, id2 = null; + pair = this.matches[i][j]; + // Resolve matches. + id1 = importMatchItem(i, j, + pair[0], + this.assignedIds, + this.missingId); + id2 = importMatchItem(i, j, + pair[1], + this.assignedIds, + this.missingId); + // Create resolved matches: + // Array. + matched[i][j] = [id1, id2]; + // Obj. + if (this.doObjLists) { + matchedObj[i][id1] = id2; + matchedObj[i][id2] = id1; + } + // By Id. + if (this.doIdLists) { + if (!matchedId[id1]) matchedId[id1] = new Array(lenI); + if (!matchedId[id2]) matchedId[id2] = new Array(lenI); + matchedId[id1][i] = id2; + matchedId[id2][i] = id1; + } + // Roles. + if (this.doRoles) { + roles[i][j] = this.roler.rolify(matched[i][j], i, j); + // TODO: this code is repeated in Roler.rolifyAll. + // make it one! + r1 = roles[i][j][0]; + r2 = roles[i][j][1]; + rolesObj[i][j] = {}; + if (r1 !== r2) { + rolesObj[i][j][r1] = id1; + rolesObj[i][j][r2] = id2; + } + else { + rolesObj[i][j][r1] = [ id1, id2 ]; + } + idRolesObj[i][j] = {}; + idRolesObj[i][j][id1] = r1; + idRolesObj[i][j][id2] = r2; + } + } + } + // Substitute matching-structure. + this.resolvedMatches = matched; + this.resolvedMatchesObj = matchedObj; + this.resolvedMatchesById = matchedId; + this.roles = roles; + this.rolesObj = rolesObj; + if (this.doRoles) { + this.roler.setRolifiedMatches(roles, false); + this.roler.setRole2IdMatches(rolesObj, false); + this.roler.setId2RoleMatches(idRolesObj, false); + } + // Set getMatch indexes to 0. + this.x = null; + this.y = null; + }; + + /** + * ### Matcher.hasNext + * + * Returns TRUE if there is next match to be returned by getMatch + * + * @param {number} x Optional. The x-th round. Default: Matcher.x + * @param {number} y Optional. The y-th match within the x-th round + * Default: Matcher.y + * + * @return {bolean} TRUE, if there exists a next match + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.resolvedMatches + * @see hasOrGetNext + */ + Matcher.prototype.hasNext = function(x, y) { + return hasOrGetNext.call(this, 'hasNext', 0, x, y); + }; + + /** + * ### Matcher.getMatch + * + * Returns the next match, or the specified match + * + * @param {number} x Optional. The x-th round. Default: Matcher.x + * @param {number} y Optional. The y-th match within the x-th round. + * Default: Matcher.y + * + * @return {array} The next or requested match, or null if not found + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.resolvedMatches + * @see hasOrGetNext + */ + Matcher.prototype.getMatch = function(x, y) { + return hasOrGetNext.call(this, 'getMatch', 1, x, y); + }; + + /** + * ### Matcher.getMatchFor + * + * Returns the id/s of the next or the x-th match for the specified id + * + * If id lists are not generated (see `Matcher.doIdLists) an + * error is thrown. + * + * @param {string} id The id to get the matches for + * @param {number} x Optional. The x-th round. Default: Matcher.x + * + * @return {string|array} The next or requested match, or null if not found + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.doIdLists + * @see Matcher.resolvedMatches + * @see hasOrGetNext + */ + Matcher.prototype.getMatchFor = function(id, x) { + var out; + if ('string' !== typeof id) { + throw new TypeError('Matcher.getMatchFor: id must be string. ' + + 'Found:' + id); + } + if (!this.resolvedMatchesById) { + throw new Error('Matcher.getMatchFor: no id-based matches found.'); + } + out = this.resolvedMatchesById[id]; + if (!out) return null; + if ('undefined' === typeof x) return out; + if ('number' === typeof x) { + if (x >= 0 && !isNaN(x)) return x > (out.length -1) ? null : out[x]; + } + throw new TypeError('Matcher.getMatchFor: x must be a positive ' + + 'number or undefined. Found: ' + x); + }; + + /** + * ### Matcher.getMatchObject + * + * Returns all the matches of the next or requested round as key-value pairs + * + * If object lists are not generated (see `Matcher.doObjLists) an + * error is thrown. + * + * @param {number} x Optional. The x-th round. Default: Matcher.x + * @param {number} y Optional. The y-th match within the x-th round. + * Default: Matcher.y + * + * @return {object|null} The next or requested match, or null if not found + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.doObjLists + * @see Matcher.resolvedMatchesObj + */ + Matcher.prototype.getMatchObject = function(x, y) { + if (!this.resolvedMatchesObj) { + throw new Error('Matcher.getMatchObject: no obj matches found.'); + } + return hasOrGetNext.call(this, 'getMatchObject', 3, x, y); + }; + + /** + * ### Matcher.normalizeRound + * + * Returns the round index given the current number of matches + * + * For example, if the are only 10 matches repeated in cycle, + * but the game has 20 rounds, round 13th will have normalized + * round index equal to 3. + * + * Important! Matches are 0-based, but rounds are 1-based. This + * method takes care of it. + * + * @param {number} round The round to normalize + * + * @return {object} The next or requested match, or null if not found + * + * @see Matcher.x + * @see Matcher.matches + */ + Matcher.prototype.normalizeRound = function(round) { + if (!this.matches) { + throw new TypeError('Matcher.normalizeRound: no matches found.'); + } + if ('number' !== typeof round || isNaN(round) || round < 1) { + throw new TypeError('Matcher.normalizeRound: round must be a ' + + 'number > 0. Found: ' + round); + } + return (round-1) % this.matches.length; + }; + + /** + * ### Matcher.replaceId + * + * Replaces an id with a new one in all matches + * + * @param {string} oldId The id to be replaced + * @param {string} newId The replacing id + * + * @return {boolean} TRUE, if the oldId was found and replaced + * + * @see MatcherManager.replaceId + * @see Roler.replaceId + */ + Matcher.prototype.replaceId = function(oldId, newId) { + var m; + var i, len, j, lenJ, h, lenH; + var rowFound; + if ('string' !== typeof oldId) { + throw new TypeError('Matcher.replaceId: oldId should be string. ' + + 'Found: ' + oldId); + } + if ('string' !== typeof newId || newId.trim() === '') { + throw new TypeError('Matcher.replaceId: newId should be a ' + + 'non-empty string. Found: ' + newId); + } + + // No id was assigned yet. + if (!this.resolvedMatches) return false; + + // IdsMap. + m = this.idsMap[oldId]; + if ('undefined' === typeof m) return false; + + this.idsMap[newId] = true; + delete this.idsMap[oldId]; + + // Ids. + m = this.ids; + i = -1, len = m.length; + for ( ; ++i < len ; ) { + if (m[i] === oldId) { + m[i] = newId; + break; + } + } + + // AssignedIds and AssignedIdsMap. + m = this.assignedIdsMap; + m[newId] = m[oldId]; + delete m[oldId]; + this.assignedIds[m[newId]] = newId; + + // Update resolvedMatches. + m = this.resolvedMatches; + if (!m) return true; + + i = -1, len = m.length; + for ( ; ++i < len ; ) { + j = -1, lenJ = m[i].length; + rowFound = false; + for ( ; ++j < lenJ ; ) { + h = -1, lenH = m[i][j].length; + for ( ; ++h < lenH ; ) { + if (m[i][j][h] === oldId) { + m[i][j][h] = newId; + rowFound = true; + break; + } + } + if (rowFound) break; + } + } + + // Update resolvedMatchesObj. + m = this.resolvedMatchesObj; + + i = -1, len = m.length; + for ( ; ++i < len ; ) { + for (j in m[i]) { + if (m[i].hasOwnProperty(j)) { + if (j === oldId) { + // Do the swap. + m[i][newId] = m[i][oldId]; + m[i][m[i][oldId]] = newId; + delete m[i][oldId]; + break; + } + } + } + } + + // Update resolvedMatchesById. + m = this.resolvedMatchesById; + for (i in m) { + if (m.hasOwnProperty(i)) { + if (i === oldId) { + m[newId] = m[oldId]; + delete m[oldId]; + } + else { + lenJ = m[i].length; + // THIS OPTIMIZATION DOES NOT SEEM TO WORK. + // In fact, there might be more matches with the same + // partner in sequence. + // And also if === 1, it should be checked. + // if (lenJ == 1) { + // m[i][0] = newId; + // } + // else if (lenJ === 2) { + // if (m[i][0] === oldId) m[i][0] = newId; + // else m[i][1] = newId; + // } + // else { + j = -1; + for ( ; ++j < lenJ ; ) { + if (m[i][j] === oldId) { + m[i][j] = newId; + } + } + // } + } + } + } + + return true; + }; + + /** + * ### Matcher.clear + * + * Clears the matcher as it would be a newly created object + */ + Matcher.prototype.clear = function() { + this.x = null; + this.y = null; + this.matches = null; + this.resolvedMatches = null; + this.resolvedMatchesObj = null; + this.ids = null; + this.assignedIds = null; + this.idsMap = null; + this.assignedIdsMap = null; + this.assignerCb = Matcher.randomAssigner; + this.missingId = Matcher.missingId; + this.bye = Matcher.bye; + }; + + // ## Helper methods. + + /** + * ### importMatchItem + * + * Handles importing items from the matches array + * + * Items in matches array must be numbers or strings. If numbers + * they are translated into an id using the supplied map, otherwise + * they are considered as already an id. + * + * Items that are not numbers neither strings will throw an error. + * + * @param {number} i The row-id of the item + * @param {number} j The position in the row of the item + * @param {string|number} item The item to check + * @param {array} map The map of positions to ids + * @param {string} miss The id of number that cannot be resolved in map + * + * @return {string} The resolved id of the item + */ + function importMatchItem(i, j, item, map, miss) { + if ('number' === typeof item) { + return 'undefined' !== typeof map[item] ? map[item] : miss; + } + else if ('string' === typeof item) { + return item; + } + throw new TypeError('Matcher.match: items can be only string or ' + + 'number. Found: ' + item + ' at position ' + + i + ',' + j); + } + + /** + * ### resetResolvedData + * + * Resets resolved data of a matcher object + * + * @param {Matcher} matcher The matcher to reset + */ + function resetResolvedData(matcher) { + matcher.resolvedMatches = null; + matcher.resolvedMatchesObj = null; + matcher.resolvedMatchesById = null; + matcher.assignedIds = null; + matcher.assignedIdsMap = null; + } + + /** + * ### pairMatcherOld + * + * Creates tournament schedules for different algorithms + * + * @param {string} alg The name of the algorithm + * @param {number|array} n The number of participants (>1) or + * an array containing the ids of the participants + * @param {object} options Optional. Configuration object + * contains the following options: + * + * - bye: identifier for dummy competitor. Default: -1. + * - skypeBye: flag whether players matched with the dummy + * competitor should be added or not. Default: true. + * - rounds: number of rounds to repeat matching. Default: + * - cycle: if there are more rounds than possible combinations + * this option specifies how to fill extra rounds. Available + * settings: + * + * - 'repeat': repeats all available matches (default) + * - 'repeat_invert': repeats all available matches, but inverts + * the position of ids in the match + * - 'mirror': repeats all available matches in mirrored order. + * - 'mirror_invert': repeats all available matches in mirrored + * order and also inverts the position of the ids in the match + * + * @return {array} matches The matches according to the algorithm + */ + function pairMatcher(alg, n, options) { + var ps, matches, bye; + var i, lenI, j, lenJ, jj; + var id1, id2; + var roundsLimit, cycle, cycleI, skipBye; + var fixedRolesNoSameMatch; + + if ('number' === typeof n && n > 1) { + ps = J.seq(0, (n-1)); + } + else if (J.isArray(n) && n.length > 1) { + ps = n.slice(); + n = ps.length; + } + else { + throw new TypeError('pairMatcher.' + alg + ': n must be ' + + 'number > 1 or array of length > 1.'); + } + options = options || {}; + + bye = 'undefined' !== typeof options.bye ? options.bye : -1; + skipBye = options.skipBye || false; + + // Make sure we have even numbers. + if ((n % 2) === 1) { + ps.push(bye); + n += 1; + } + + // Does not work. + if (options.fixedRoles && (options.canMatchSameRole === false)) { + fixedRolesNoSameMatch = true; + } + + // Limit rounds. + if ('number' === typeof options.rounds) { + if (options.rounds <= 0) { + throw new Error('pairMatcher.' + alg + ': options.rounds ' + + 'must be a positive number or undefined. ' + + 'Found: ' + options.rounds); + } + if (options.rounds > (n-1)) { + throw new Error('pairMatcher.' + alg + ': ' + + 'options.rounds cannot be greater than ' + + (n-1) + '. Found: ' + options.rounds); + } + // Here roundsLimit does not depend on n (must be smaller). + roundsLimit = options.rounds; + } + else if (fixedRolesNoSameMatch) { + roundsLimit = Math.floor(n/2); + } + else { + roundsLimit = n-1; + } + + if ('undefined' !== typeof options.cycle) { + cycle = options.cycle; + if (cycle !== 'mirror_invert' && cycle !== 'mirror' && + cycle !== 'repeat_invert' && cycle !== 'repeat') { + + throw new Error('pairMatcher.' + alg + ': options.cycle ' + + 'must be equal to "mirror"/"mirror_invert", ' + + '"repeat"/"repeat_invert" or undefined . ' + + 'Found: ' + options.cycle); + } + + matches = new Array(roundsLimit*2); + } + else { + matches = new Array(roundsLimit); + } + + i = -1, lenI = roundsLimit; + for ( ; ++i < lenI ; ) { + // Shuffle list of ids for random. + if (alg === 'random') ps = J.shuffle(ps); + // Create a new array for round i. + lenJ = n / 2; + matches[i] = skipBye ? new Array(lenJ-1) : new Array(lenJ); + // Check if new need to cycle. + if (cycle) { + if (cycle === 'mirror' || cycle === 'mirror_invert') { + cycleI = (roundsLimit*2) -i -1; + } + else { + cycleI = i+roundsLimit; + } + matches[cycleI] = skipBye ? + new Array(lenJ-1) : new Array(lenJ); + } + // Counter jj is updated only if not skipBye, + // otherwise we create holes in the matches array. + jj = j = -1; + for ( ; ++j < lenJ ; ) { + if (fixedRolesNoSameMatch) { + id1 = ps[j*2]; + id2 = ps[((i*2)+(j*2)+1) % n]; + } + else { + id1 = ps[j]; + id2 = ps[n - 1 - j]; + } + if (!skipBye || (id1 !== bye && id2 !== bye)) { + jj++; + // Insert match. + matches[i][jj] = [ id1, id2 ]; + // Insert cycle match (if any). + if (cycle === 'repeat') { + matches[cycleI][jj] = [ id1, id2 ]; + } + else if (cycle === 'repeat_invert') { + matches[cycleI][jj] = [ id2, id1 ]; + } + else if (cycle === 'mirror') { + matches[cycleI][jj] = [ id1, id2 ]; + } + else if (cycle === 'mirror_invert') { + matches[cycleI][jj] = [ id2, id1 ]; + } + } + } + // Permutate for next round. + if (!fixedRolesNoSameMatch) ps.splice(1, 0, ps.pop()); + } + return matches; + } + + /** + * ## fetchMatch + * + * Maps method names to a return function to execute in case of success + * + * - 0: hasNext -> returns true + * - 1: getMatch -> returns an array, or array of arrays + * - 2: getMatchFor -> returns a string + * - 3: getMatchObject -> returns an object + * + * @see hasOrGetNext + */ + fetchMatch = [ + // hasNext. + function() { + return true; + }, + // getMatch. + function(x, y) { + return 'number' === typeof y ? + this.resolvedMatches[x][y] : this.resolvedMatches[x]; + }, + // getMatchFor. + function(x, y, id) { + if ('number' === typeof x && 'number' === typeof y) { + return this.resolvedMatchesById[id][x]; + } + return this.resolvedMatchesById[id]; + }, + // getMatchObject. + function(x, y) { + var match, res; + if ('number' === typeof y) { + res = {}; + match = this.resolvedMatches[x][y]; + res[match[0]] = match[1]; + res[match[1]] = match[0]; + return res; + } + return this.resolvedMatchesObj[x]; + } + ]; + + /** + * ### hasOrGetNext + * + * Returns TRUE or the match if there is next match + * + * If in `get` mode it also updates the x and y indexes. + * + * @param {string} m The name of the method invoking it + * @param {boolean} get TRUE, if the method should return the match + * @param {number} x Optional. The x-th round. Default: Matcher.x + * @param {number} y Optional. The y-th match within the x-th round + * Default: Matcher.y + * @param {string} id Optional. Used by method getMatchFor + * + * @return {boolean|array|null} TRUE or the next match (if found), + * FALSE or null (if not found) + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.resolvedMatches + * @see fetchMatch + */ + function hasOrGetNext(m, mod, x, y, id) { + var nRows, nCols; + + // Check if there is any match yet. + if (!J.isArray(this.resolvedMatches) || !this.resolvedMatches.length) { + throw new Error('Matcher.' + m + ': no resolved matches found.'); + } + + nRows = this.resolvedMatches.length - 1; + + // No x, No y get the next match. + if ('undefined' === typeof x) { + // Check both x and y. + if ('undefined' !== typeof y) { + throw new Error('Matcher.' + m + + ': cannot specify y without x.'); + } + + // No match was ever requested. + if (null === this.x) { + this.x = 0; + this.y = 0; + return fetchMatch[mod].call(this, 0, 0, id); + } + + x = this.x; + y = this.y + 1; + if (x <= nRows) { + nCols = this.resolvedMatches[x].length - 1; + if (y <= nCols) { + if (mod) { + this.x = x; + this.y = y; + return fetchMatch[mod].call(this, x, y, id); + // return this.resolvedMatches[x][y]; + } + else { + return true; + } + } + else { + x = x + 1; + y = 0; + if (mod) { + this.x = x; + this.y = y; + } + if (x <= nRows) { + return fetchMatch[mod].call(this, x, y, id); + // return mod ? this.resolvedMatches[x][y] : true; + } + else { + return mod ? null : false; + } + } + } + else { + return mod ? null : false; + } + } + // End undefined x. + + // Validate x. + if ('number' !== typeof x) { + throw new TypeError('Matcher.' + m + ': x must be number ' + + 'or undefined. Found: ' + x); + } + else if (x < 0 || isNaN(x)) { + throw new Error('Matcher.' + m + ': x cannot be negative or NaN. ' + + 'Found: ' + x); + } + + if (x > nRows) { + if (mod) { + this.x = x; + this.y = 0; + return null; + } + else { + return false; + } + } + + // Default y (whole row). + if ('undefined' === typeof y) { + if (mod) { + this.x = x; + this.y = this.resolvedMatches[nRows].length; + // Return the whole row. + return fetchMatch[mod].call(this, x, y, id); + // return this.resolvedMatches[x]; + } + else { + return true; + } + } + + // Validate y. + if ('number' !== typeof y) { + throw new TypeError('Matcher.' + m + ': y must be number ' + + 'or undefined.'); + } + else if (y < 0 || isNaN(y)) { + throw new Error('Matcher.' + m + ': y cannot be negative or NaN. ' + + 'Found: ' + y); + } + + nCols = this.resolvedMatches[x].length - 1; + + // Valid x,y match. + if (y <= nCols) { + if (mod) { + this.x = x; + this.y = y; + return fetchMatch[mod].call(this, x, y); + // return this.resolvedMatches[x][y]; + } + else { + return true; + } + } + // Out of bound. + else { + if (mod) { + this.x = x; + this.y = y; + return null; + } + else { + return false; + } + } + } + + // ## Closure +})( + 'undefined' !== typeof node ? node : module.exports, + 'undefined' !== typeof node ? node : module.parent.exports +); + +/** + * # MatcherManager + * Copyright(c) 2020 Stefano Balietti + * MIT Licensed + * + * Handles matching roles to players and players to players. + * + * --- + * nodegame.org + */ +(function(exports, parent) { + + "use strict"; + + exports.MatcherManager = MatcherManager; + + /** + * ## MatcherManager constructor + * + * Creates a new instance of role mapper + */ + function MatcherManager(node) { + + /** + * ### MatcherManager.node + * + * Reference to the node object + */ + this.node = node; + + /** + * ### MatcherManager.roler + * + * The roler object + * + * @see Roler + */ + this.roler = new parent.Roler(); + + /** + * ### MatcherManager.matcher + * + * The matcher object + * + * @see Matcher + */ + this.matcher = new parent.Matcher({ roler: this.roler }); + + /** + * ### MatcherManager.lastSettings + * + * Reference to the last settings parsed + */ + this.lastSettings = null; + + /** + * ### MatcherManager.lastMatches + * + * Reference to the last matches + */ + this.lastMatches = null; + + /** + * ### MatcherManager.lastMatchesById + * + * Reference to the last matches organized by id of client + */ + this.lastMatchesById = {}; + } + + /** + * ### MatcherManager.clear + * + * Clears current matches and roles + * + * @param {string} mod Optional. Modifies what must be cleared. + * Values: 'roles', 'matches', 'all'. Default: 'all' + */ + MatcherManager.prototype.clear = function(mod) { + + this.lastMatches = null; + this.lastSettings = null; + this.lastMatchesById = {}; + + switch(mod) { + case 'roles': + this.roler.clear(); + break; + case 'matches': + this.matcher.clear(); + break; + default: + this.roler.clear(); + this.matcher.clear(); + } + }; + + /** + * ### MatcherManager.match + * + * Parses a conf object and returns the desired matches of roles and players + * + * Stores references of last settings and matches. + * + * Returned matches are in a format which is ready to be sent out as + * remote options. That is: + * + * matches = [ + * { + * id: 'playerId', + * options: { + * role: "A", // Optional. + * partner: "partnerId", // Optional. + * group: "yyy" // For future use. + * } + * }, + * // More matches... + * ]; + * + * @param {object} settings The settings to generate the matches. + * The object is passed to `Matcher.match` + * + * @return {array} Array of matches ready to be sent out as remote options. + * + * @see randomPairs + * @see MatcherManager.lastMatches + * @see MatcherManager.lastMatchesById + * @see MatcherManager.lastSettings + * @see Matcher.match + * @see Game.gotoStep + */ + MatcherManager.prototype.match = function(settings) { + var matches; + + // String is turned into object. Might still fail. + if ('string' === typeof settings) settings = { match: settings }; + + if ('object' !== typeof settings || settings === null) { + throw new TypeError('MatcherManager.match: settings must be ' + + 'object or string. Found: ' + settings); + } + + if (settings.match === 'random_pairs' || + (settings.match === 'round_robin' || + settings.match === 'roundrobin')) { + + matches = randomPairs.call(this, settings); + } + else { + throw new Error('MatcherManager.match: only "random_pairs" and ' + + '"round_robin" algorithms supported. Found: ' + + settings.match); + } + + if (!matches || !matches.length) { + throw new Error('MatcheManager.match: "' + settings.match + + '" did not return matches.'); + } + + return matches; + }; + + /** + * ### MatcherManager.getMatches + * + * Returns all the matches in a round in the requested format + * + * Accepts two parameters to specify a round, and a modifier for + * the return value. Important! Both parameters are optional and + * they can be passed in either order. + * + * Valid modifiers and return values: + * + * - 'ARRAY' (default): [ [ 'id1', 'id2' ], [ 'id3', 'id4' ], ... ] + * + * - 'ARRAY_ROLES': [ [ 'ROLE1', 'ROLE2' ], [ 'ROLE1', 'ROLE2' ] ] + * + * - 'ARRAY_ROLES_ID': [ { ROLE1: 'id1', ROLE2: 'id2' }, + * { ROLE1: 'id3', ROLE2: 'id4' }, ... ] + * + * - 'ARRAY_ID_ROLES': [ { id1: 'ROLE1', id2: 'ROLE2' }, + * { id3: 'ROLE1', id4: 'ROLE4' }, ... ] + * + * - 'OBJ': { id1: 'id2', id2: 'id1', id3: 'id4', id4: 'id3' } + * + * - 'OBJ_ROLES_ID': { ROLE1: [ 'id1', 'id3' ], ROLE2: [ 'id2', 'id4' ] } + * + * - 'OBJ_ID_ROLES': { id1: 'ROLE1', id2: 'ROLE2', + * id3: 'ROLE1', id4: 'ROLE2' } + * + * @param {string} mod Optional. A valid modifier (default: 'ARRAY') + * @param {number} round Optional. The round of the matches + * (default: current game round). + * + * @return {array|object|null} The requested matches in the requested + * format, or null matches are not yet set + * + * @see round2Index + * @see Matcher.getMatch + * @see Matcher.getMatchObject + * @see Roler.getRoleObj + * @see Roler.getIdRoleObj + */ + MatcherManager.prototype.getMatches = function(mod, round) { + + if ('string' !== typeof mod) { + if ('undefined' !== typeof mod) { + throw new TypeError('MatcherManager.getMatches: mod must be ' + + 'undefined or string. Found: ' + mod); + } + mod = 'ARRAY'; + } + + if ('undefined' !== typeof round && 'number' !== typeof round) { + throw new TypeError('MatcherManager.getMatches: round ' + + 'must be undefined or number. Found: ' + round); + } + + if (!this.matcher.getMatches()) return null; + + round = round2Index.call(this, 'getMatches', round); + + if (mod === 'ARRAY') return this.matcher.getMatch(round); + if (mod === 'ARRAY_ROLES') return this.roler.getRoleMatch(round); + if (mod === 'ARRAY_ID_ROLES') return this.roler.getId2RoleMatch(round); + if (mod === 'ARRAY_ROLES_ID') return this.roler.getRole2IdMatch(round); + + if (mod === 'OBJ') return this.matcher.getMatchObject(round); + if (mod === 'OBJ_ROLES_ID') return this.roler.getRole2IdRoundMap(round); + if (mod === 'OBJ_ID_ROLES') return this.roler.getId2RoleRoundMap(round); + + throw new Error('MatcherManager.getMatches: unknown modifier: ' + mod); + }; + + /** + * ### MatcherManager.getMatchFor + * + * Returns the match for the specified id + * + * @param {string} id The id to search a match for + * @param {number} round Optional. Specifies a round other + * than current (will be normalized if there are more + * rounds than matches) + * + * @return {string|null} The current match for the id, or null + * if the id is not found or matches are not set + * + * @see Matcher.getMatchFor + * @see round2Index + */ + MatcherManager.prototype.getMatchFor = function(id, round) { + if (!this.matcher.getMatches()) return null; + round = round2Index.call(this, 'getMatchFor', round); + return this.matcher.getMatchFor(id, round); + }; + + /** + * ### MatcherManager.getRoleFor + * + * Returns the role for the specified id + * + * @param {string} id The id to search a role for + * @param {number} round Optional. Specifies a round other + * than current (will be normalized if there are more + * rounds than matches) + * + * @return {string|null} The role hold by id at the + * specified round or null if matches are not yet set + * + * @see Roler.getRolerFor + * @see round2Index + */ + MatcherManager.prototype.getRoleFor = function(id, round) { + if (!this.matcher.getMatches()) return null; + round = round2Index.call(this, 'getRoleFor', round); + return this.roler.getRoleFor(id, round); + }; + + /** + * ### Roler.getIdForRole + * + * Returns the id/s holding a roles at round x + * + * @param {string} role The role to check + * @param {number} round Optional. Specifies a round other + * than current (will be normalized if there are more + * rounds than matches) + * + * @return {array|null} Array of id/s holding the role at round x, or + * null if matches are not yet set + * + * @see Roler.getIdForRole + * @see round2Index + */ + MatcherManager.prototype.getIdForRole = function(role, round) { + if (!this.matcher.getMatches()) return null; + round = round2Index.call(this, 'getIdForRole', round); + return this.roler.getIdForRole(role, round); + }; + + /** + * ### MatcherManager.getIterationRound + * + * Returns the pointer the round in the matcher (matches are cycled through) + * + * @return {number} The current iteration round + * + * @see Matcher.x + * @see Matcher.hasNext + */ + MatcherManager.prototype.getIterationRound = function() { + return this.matcher.x || 0; + }; + + /** + * ### MatcherManager.replaceId + * + * Replaces an id with a new one in all roles and matches + * + * If the number of players and rounds is large, + * this operation becomes costly. Consider replacing the ID + * manually after being returned. + * + * @param {string} oldId The id to be replaced + * @param {string} newId The replacing id + * + * @return {boolean} TRUE, if the oldId was found and replaced + * + * @see Matcher.replaceId + * @see Roler.replaceId + * + * @experimental + * + * TODO: this does not scale up. Maybe have another registry of + * substituted ids. + * + * TODO: maybe return info about the replaced id, e.g. current + * options, instead of boolean. + */ + MatcherManager.prototype.replaceId = function(oldId, newId) { + var res; + res = this.matcher.replaceId(oldId, newId); + res = res && this.roler.replaceId(oldId, newId); + return res; + }; + + /** + * ### MatcherManager.getSetupFor + * + * Returns the setup object (partner and role options) for a specific id + * + * @param {string} id The id to get the setup object for + * + * @return {object|null} The requested setup object or null if not found + * + * @see Matcher.match + * @see round2index + */ + MatcherManager.prototype.getSetupFor = function(id) { + var out; + if ('string' !== typeof id) { + throw new TypeError('MatcherManager.getSetupFor: id must be ' + + 'string. Found: ' + id); + } + out = this.lastMatchesById[id]; + return out || null; + }; + + // ## Helper Methods. + + /** + * ### round2Index + * + * Parses a round into corresponding index of matches + * + * Important! Matches are 0-based, but rounds are 1-based. + * `Matcher.normalizeRound` takes care of it. + * + * @param {number} round Optional. The round to parse to an index. + * Default: current game round. + * + * @return {number} The normalized round + * + * @see Matcher.normalizeRound + * @see Matcher.x + * @see Game.getCurrentGameStage + */ + function round2Index(method, round) { + if ('undefined' === typeof round) { + round = this.node.game.getRound(); + if (round === 0) { + throw new Error('MatcherManager.' + method + ': game stage ' + + 'is 0.0.0, please specify a valid round'); + } + } + if ('number' === typeof round) { + round = this.matcher.normalizeRound(round); + } + return round; + } + + /** + * ### randomPairs + * + * Matches players and/or roles in random pairs + * + * Supports odd number of players, if 3 roles are given in settings. + * + * @param {object} settings The settings object + * + * @return {array} The array of matches. + */ + function randomPairs(settings) { + var r1, r2; + var ii, i, len; + var roundMatches, nMatchesIdx, match, id1, id2, missId; + var matches, matchesById, sayPartner, doRoles; + var opts, roles, matchedRoles; + + var game, n; + var nRounds; + + // Delete previous results. + this.lastMatches = null; + this.lastMatchesById = null; + + // Init local variables. + + matchesById = {}; + + sayPartner = 'undefined' === typeof settings.sayPartner ? + true : !!settings.sayPartner; + + doRoles = !!settings.roles; + + game = this.node.game; + n = game.pl.size(); + + // Settings the number of rounds. + if ('undefined' !== typeof settings.rounds) { + nRounds = settings.rounds; + } + else { + nRounds = game.plot.getRound(game.getNextStep(), 'total'); + } + if (nRounds > n-1) nRounds = n-1; + + // Algorithm: random. + if (settings.match === 'random') { + if (doRoles) { + this.roler.clear(); + this.roler.setRoles(settings.roles, 2); + this.matcher.init({ doRoles: doRoles }); + } + this.matcher.generateMatches('random', n, { + rounds: nRounds, + // cycle: settings.cycle, + skipBye: settings.skipBye, + bye: settings.bye, + fixedRoles: settings.fixedRoles, + canMatchSameRole: settings.canMatchSameRole + }); + this.matcher.setIds(game.pl.id.getAllKeys()); + // Generates new random matches for this round. + this.matcher.match(true); + } + + // Algorithm: round robin (but only if not already initialized + // or if reInit = true). + else { + if (!this.matcher.matches || settings.reInit) { + if (doRoles) { + this.roler.clear(); + this.roler.setRoles(settings.roles, 2); + this.matcher.init({ doRoles: doRoles }); + } + // Make a manual copy of settings object, and generate matches. + this.matcher.generateMatches('roundrobin', n, { + rounds: nRounds, + cycle: settings.cycle, + skipBye: settings.skipBye, + bye: settings.bye, + fixedRoles: settings.fixedRoles, + canMatchSameRole: settings.canMatchSameRole + }); + if (settings.assignerCb) { + this.matcher.setAssignerCb(settings.assignerCb); + } + this.matcher.setIds(game.pl.id.getAllKeys()); + // Generates matches. + this.matcher.match(true); + } + // Cycle through the matches, if we do not have enough. + else if (!this.matcher.hasNext()) { + this.matcher.init( { x: null, y: null }); + } + } + + // Get all the matches for round x, and increments x. + nMatchesIdx = 'number' === typeof this.matcher.x ? + (this.matcher.x + 1) : 0; + // This also increments the index matcher.x. + roundMatches = this.matcher.getMatch(nMatchesIdx); + + len = roundMatches.length; + + // Contains one remoteOptions object per player. + matches = ((n % 2) === 0) ? + new Array((len*2)) : + (settings.skipBye ? new Array((len*2)-2) : new Array((len*2)-1)); + + // The id in case the number of player is odd. + missId = this.matcher.missingId; + + matchedRoles = this.roler.getRolifiedMatches(); + + // While we have matches, send them to clients. + ii = i = -1; + for ( ; ++i < len ; ) { + ii++; + match = roundMatches[i]; + id1 = match[0]; + id2 = match[1]; + + // Verify that id1 and id2 are still connected. + if (!game.pl.exist(id1)) id1 = missId; + if (!game.pl.exist(id2)) id2 = missId; + + // If both id1 and id2 are disconnected, skip matching them. + if (id1 === id2) { + // Reduce matches array length. + len--; + matches.length--; + continue; + } + + if (doRoles) { + roles = matchedRoles[nMatchesIdx][i]; + + // Prepare options to send to player 1, if role1 is defined. + r1 = roles[0]; + + if (r1) { + if (!sayPartner) { + opts = { id: id1, options: { role: r1 } }; + } + else { + opts = { id: id1, options: { role: r1, partner: id2 } }; + } + // Add options to array. + matches[ii] = opts; + + // Keep reference. + matchesById[id1] = opts.options; + } + + // Prepare options to send to player 2, if role2 is defined. + r2 = roles[1]; + + if (r2) { + + // Increment ii index if both r1 and r2 are defined. + if (r1) ii++; + + if (!sayPartner) { + opts = { id: id2, options: { role: r2 } }; + } + else { + opts = { id: id2, options: { role: r2, partner: id1 } }; + } + // Add options to array. + matches[ii] = opts; + + // Keep reference. + matchesById[id2] = opts.options; + } + } + else if (sayPartner) { + if (id1 !== missId) { + opts = { id: id1, options: { partner: id2 } }; + matches[ii] = opts; + matchesById[id1] = opts.options; + } + if (id2 !== missId) { + if (id1 !== missId) ii++; + opts = { id: id2, options: { partner: id1 } }; + matches[ii] = opts; + matchesById[id2] = opts.options; + } + } + } + + // Store references. + this.lastMatches = matches; + this.lastMatchesById = matchesById; + this.lastSettings = settings; + + return matches; + } + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # GameDB + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * Provides a simple, lightweight NO-SQL database for nodeGame + * + * It automatically indexes inserted items by: + * + * - player, + * - stage. + * + * @see GameStage.compare + * @see NDDB + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope. + var NDDB = parent.NDDB, + GameStage = parent.GameStage, + J = parent.JSUS; + + // Inheriting from NDDB. + GameDB.prototype = new NDDB(); + GameDB.prototype.constructor = GameDB; + + // Expose constructors + exports.GameDB = GameDB; + + /** + * ## GameDB constructor + * + * Creates an instance of GameDB + * + * @param {object} options Optional. A configuration object + * @param {array} db Optional. An initial array of items + * + * @see NDDB constructor + */ + function GameDB(options, db) { + var that; + that = this; + options = options || {}; + options.name = options.name || 'memory'; + + if (!options.update) options.update = {}; + + // Auto build indexes by default. + options.update.indexes = true; + + // TODO: move on server-side only. + options.defaultCSVHeader = [ + 'session', 'treatment', 'player', 'stage', 'step', 'timestamp', + 'time', 'timeup' + ]; + + // Experimental. TODO. + options.skipCSVKeys = { + isCorrect: true, + id: true, + done: true + }; + + NDDB.call(this, options, db); + + this.comparator('stage', function(o1, o2) { + var _o2; + if ('string' === typeof o2.stage && that.node) { + if (false === J.isInt(o2.stage)) { + _o2 = that.node.game.plot.normalizeGameStage(o2.stage); + if (_o2) o2.stage = _o2; + } + } + return GameStage.compare(o1.stage, o2.stage); + }); + + this.hash('player', function(o) { + return o.player; + }); + + this.hash('stage', function(o) { + if (o.stage) return GameStage.toHash(o.stage, 'S.s.r'); + }); + + this.view('done'); + + // TODO: move on server-side only. + this.on('save', function(opts, info) { + if (opts.append) opts.flags = 'a'; + if (info.format === 'csv') decorateCSVSaveOptions(that, opts); + }, true); + + this.stepView = function(step) { + return this.view(step, function(item) { + if (that.node.game.isStep(step, item.stage)) return true; + }); + }; + + this.stageView = function(stage) { + return this.view(stage, function(item) { + if (that.node.game.isStage(stage, item.stage)) return true; + }); + }; + + this.node = this.__shared.node; + } + + /** + * ### GameDB.add + * + * Wrapper around NDDB.insert + * + * Checks that the object contains a player and stage + * property and also adds a timestamp and session field. + * + * @param {object} o The object to add + * + * @NDDB.insert + */ + GameDB.prototype.add = function(o) { + if ('string' !== typeof o.player) { + throw new TypeError('GameDB.add: player missing or invalid: ', o); + } + if ('object' !== typeof o.stage) { + throw new Error('GameDB.add: stage missing or invalid: ', o); + } + + if (!o.timestamp) o.timestamp = Date.now ? + Date.now() : new Date().getTime(); + + o.session = this.node.nodename; + + o.treatment = this.node.game.settings.treatmentName; + + this.insert(o); + }; + + /** + * ### decorateCSVSaveOptions + * + * Adds default options to improve data saving. + * + * @param {object} opts Optional. The option object to decorate + */ + function decorateCSVSaveOptions(that, opts) { + var toId, split, plot; + if ('undefined' === typeof opts.bool2num) opts.bool2num = true; + + // Handle stage object. + toId = 'undefined' === typeof opts.stageNum2Id ? + true : opts.stageNum2Id; + split = 'undefined' === typeof opts.splitStage ? + true : opts.splitStage; + + plot = that.node.game.plot; + + if (!opts.adapter) opts.adapter = {}; + + if (split) { + if ('undefined' === typeof opts.adapter.stage) { + opts.adapter.stage = function(i) { + if (!i.stage) return; + return toId ? plot.getStage(i.stage).id : i.stage.stage; + }; + } + if ('undefined' === typeof opts.adapter.step) { + opts.adapter.step = function(i) { + if (!i.stage) return; + return toId ? plot.getStep(i.stage).id : i.stage.step; + }; + } + if ('undefined' === typeof opts.adapter.round) { + opts.adapter.round = function(i) { return i.stage.round; }; + } + } + else { + if ('undefined' === typeof opts.adapter.stage) { + opts.adapter.stage = function(i) { + var s = i.stage; + if (!s) return; + if (toId) { + return plot.getStage(s).id + '.' + + plot.getStep(s).id + '.' + s.round; + } + return s.stage + '.' + s.step + '.' + s.round; + }; + } + } + + // Flatten. + if (opts.flatten) { + + if ('undefined' === typeof opts.header && + 'undefined' === typeof opts.headers) { + + opts.header = that.defaultCSVHeader || 'all'; + } + + opts.preprocess = function(item, current) { + var s; + // s = item.stage.stage + '.' + item.stage.step + + // '.' + item.stage.round; + s = that.node.game.plot.getStage(item.stage).id; + s += '.' + that.node.game.plot.getStep(item.stage).id; + s += '.' + item.stage.round; + that.node.game.plot.getStage() + if (item.time) item['time_' + s] = item.time; + if (item.timeup) item['timeup_' + s] = item.timeup; + if (item.timestamp) item['timestamp_' + s] = item.timestamp; + delete item.time; + delete item.timestamp; + }; + } + } + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Game + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * Handles the flow of the game + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + + // Exposing Game constructor + exports.Game = Game; + + var GameStage = parent.GameStage, + GameMsg = parent.GameMsg, + GameDB = parent.GameDB, + GamePlot = parent.GamePlot, + PlayerList = parent.PlayerList, + Stager = parent.Stager, + PushManager = parent.PushManager, + SizeManager = parent.SizeManager, + MatcherManager = parent.MatcherManager, + J = parent.JSUS; + + var constants = parent.constants; + var stageLevels = constants.stageLevels; + var stateLevels = constants.stateLevels; + + /** + * ## Game constructor + * + * Creates a new instance of Game + * + * @param {NodeGameClient} node A valid NodeGameClient object + */ + function Game(node) { + + this.node = node; + + // This updates are never published. + this.setStateLevel(stateLevels.UNINITIALIZED, 'S'); + this.setStageLevel(stageLevels.UNINITIALIZED, 'S'); + + // ## Properties + + /** + * ### Game.metadata + * + * The game's metadata + * + * This object is normally filled-in automatically with data + * from the file `package.json` inside the game folder. + * + * Contains at least the following properties: + * + * - name, + * - description, + * - version + */ + this.metadata = { + name: 'A nodeGame game', + description: 'No description', + version: '0.0.1' + }; + + /** + * ### Game.settings + * + * The game's settings + * + * This object is normally filled-in automatically with the settings + * contained in the game folder: `game/game.settings`, + * depending also on the chosen treatment. + */ + this.settings = {}; + + /** + * ### Game.pl | playerList + * + * The list of players connected to the game + * + * The list may be empty, depending on the server settings. + * + * Two players with the same id, or any player with id equal to + * `node.player.id` is not allowed, and it will throw an error. + */ + this.playerList = this.pl = new PlayerList({ + log: this.node.log, + logCtx: this.node, + name: 'pl_' + this.node.nodename + }); + + this.pl.on('insert', function(p) { + if (p.id === node.player.id) { + throw new Error('node.game.pl.on.insert: cannot add player ' + + 'with id equal to node.player.id.'); + } + }); + + /** + * ### Game.ml | monitorList + * + * The list of monitor clients connected to the game + * + * The list may be empty, depending on the server settings + */ + this.monitorList = this.ml = new PlayerList({ + log: this.node.log, + logCtx: this.node, + name: 'ml_' + this.node.nodename + }); + + /** + * ### Game.memory + * + * A storage database for the game + * + * In the server logic the content of SET messages are + * automatically inserted in this object + * + * @see NodeGameClient.set + */ + this.memory = new GameDB({ + log: this.node.log, + logCtx: this.node, + shared: { node: this.node } + }); + + /** + * ### Game.plot + * + * The Game plot + * + * @see GamePlot + */ + this.plot = new GamePlot(this.node, new Stager()); + + // TODO: check if we need this. + // // Overriding stdout for game plot and stager. + // this.plot.setDefaultLog(function() { + // // Must use apply, else will be executed in the wrong context. + // node.log.apply(node, arguments); + // }); + + /** + * ### Game.role + * + * The "role" currently held in this game (if any) + * + * @see Game.gotoStep + * @see Game.setRole + * @see processGotoStepOptions + */ + this.role = null; + + /** + * ### Game.partner + * + * The id or alias of the "partner" in this game (if any) + * + * Some games are played in pairs, this variable holds the id + * of the partner player. + * + * @see Game.setPartner + * @see processGotoStepOptions + */ + this.partner = null; + + /** + * ### Game.matcher + * + * Handles assigning matching tasks + * + * Assigns roles to players, players to players, etc. + * + * @see Game.gotoStep + */ + this.matcher = MatcherManager ? new MatcherManager(this.node) : null; + + /** + * ### Game.timer + * + * Default game timer synced with stager 'timer' property + * + * @see GameTimer + * @see GameTimer.syncWithStager + */ + this.timer = this.node.timer.createTimer({ + name: 'game_timer', + stagerSync: true + }); + + // Setting to stage 0.0.0 and starting. + this.setCurrentGameStage(new GameStage(), 'S'); + this.setStateLevel(stateLevels.STARTING, 'S'); + + /** + * ### Game.paused + * + * TRUE, if the game is paused + * + * @see Game.pause + * @see Game.resume + */ + this.paused = false; + + /** + * ### Game.pauseCounter + * + * Counts the number of times the game was paused + * + * @see Game.pause + * @see Game.resume + */ + this.pauseCounter = 0; + + /** + * ### Game.willBeDone + * + * TRUE, if DONE was emitted and evaluated successfully + * + * If TRUE, when PLAYING is emitted `node.done` is called + * immediately, and the game tries to step forward. + * + * @see NodeGameClient.done + */ + this.willBeDone = false; + + /** + * ### Game.globals + * + * Object pointing to the current step _globals_ properties + * + * Whenever a new step is executed the _globals_ properties of + * the step are copied here. The _globals_ properties of the previous + * stage are deleted. + * + * @see GamePlot + * @see Stager + */ + this.globals = {}; + + /** + * ### Game._steppedSteps + * + * Array of steps previously played + * + * @see Game.step + * @see Game.stepBack + * + * @api private + */ + this._steppedSteps = [ new GameStage() ]; + + /** + * ### Game._breakStage + * + * Flags to break current stage at next node.done call + * + * @see Game.breakStage + */ + this._breakStage = false; + + /** ### Game.pushManager + * + * Handles pushing client to advance to next step + * + * @see PushManager + */ + this.pushManager = new PushManager(this.node); + + /** ### Game.sizeManager + * + * Handles changes in the number of connected players + * + * @see SizeManager + */ + this.sizeManager = new SizeManager(this.node); + + + /** ### Game.session + * + * Stores variables and shares them with logic and other players + * + */ + (function(that, s, msg) { + s = {}; + + that.session = function(name, value, opts) { + var to, from; + opts = opts || {}; + from = opts.from || node.player.id; + // If it is called from HTML before game is init. + if (!from) { + from = '_own_'; + node.once('PLAYER_CREATED', function(p) { + if (!s[from]) return; + s[p.id] = s[from]; + s[from] = null; + }); + } + if (!s[from]) s[from] = {}; + if (arguments.length > 1) { + s[from][name] = value; + to = 'undefined' === typeof opts.to ? 'SERVER' : opts.to; + if (to !== false) { + node.socket.send(node.msg.create({ + target: 'SESSION', + data: { name: name, value: value }, + to: to + })); + } + } + return s[from][name]; + }; + + that.session.player = function(p) { + return s[p] || {}; + }; + + })(this); + + + + + } + + // ## Game methods + + /** + * ### Game.start + * + * Starts the game + * + * Calls the init function, and steps. + * + * Important: it does not use `Game.publishUpdate` because that is + * just for change of state after the game has started. + * + * @param {object} options Optional. Configuration object. Fields: + * + * - step: {boolean}. If false, jus call the init function, and + * does not enter the first step. Default: TRUE. + * - startStage: {GameStage}. If set, the game will step into + * the step _after_ startStage after initing. Default: 0.0.0 + * - stepOptions: options to pass to the new step (only if step + * option is not FALSE). + * + * @see Game.step + */ + Game.prototype.start = function(options) { + var onInit, node, startStage; + + node = this.node; + + if (options && 'object' !== typeof options) { + throw new TypeError('Game.start: options must be object or ' + + 'undefined.'); + } + if (node.player.placeholder) { + throw new Error('Game.start: no player defined.'); + } + if (!this.isStartable()) { + throw new Error('Game.start: game cannot be started.'); + } + node.info('game started.'); + + // Store time. + node.timer.setTimestamp('start'); + + options = options || {}; + + // Starts from beginning (default) or from a predefined stage + // This options is useful when a player reconnets. + startStage = options.startStage || new GameStage(); + + // Update GLOBALS. + this.updateGlobals(startStage); + + // INIT the game. + onInit = this.plot.stager.getOnInit(); + if (onInit) { + this.setStateLevel(stateLevels.INITIALIZING); + node.emit('INIT'); + onInit.call(node.game); + } + + this.setStateLevel(stateLevels.INITIALIZED); + + this.setCurrentGameStage(startStage, 'S'); + + node.log('game started.'); + + if (options.step !== false) this.step(options.stepOptions); + }; + + /** + * ### Game.restart + * + * Stops and starts the game. + * + * @see Game.stop + * @see Game.start + */ + Game.prototype.restart = function() { + this.stop(); + this.start(); + }; + + /** + * ### Game.stop + * + * Stops the current game + * + * Clears timers, event handlers, local memory, and window frame (if any). + * + * Does **not** clear _node.env_ variables and any node.player extra + * property. + * + * GameStage is set to 0.0.0 and server is notified. + */ + Game.prototype.stop = function() { + var node; + if (!this.isStoppable()) { + throw new Error('Game.stop: game cannot be stopped.'); + } + + node = this.node; + + // Destroy currently running timers. + node.timer.destroyAllTimers(true); + + // Remove all events registered during the game. + node.events.ee.game.clear(); + node.events.ee.stage.clear(); + node.events.ee.step.clear(); + + node.socket.eraseBuffer(); + + // Clear memory. + this.memory.clear(); + + // If a _GameWindow_ object is found, clears it. + if (node.window) node.window.reset(); + + // Update state/stage levels and game stage. + this.setStateLevel(stateLevels.STARTING, 'S'); + this.setStageLevel(stageLevels.UNINITIALIZED, 'S'); + // This command is notifying the server. + this.setCurrentGameStage(new GameStage()); + + // TODO: check if we need pl and ml again. + node.game = null; + node.game = new Game(node); + node.game.pl = this.pl; + node.game.ml = this.ml; + + node.log('game stopped.'); + }; + + /** + * ### Game.gameover + * + * Ends the game + * + * Calls the gameover function, sets levels. + * + * TODO: should it set the game stage to 0.0.0 again ? + */ + Game.prototype.gameover = function() { + var onGameover, node; + node = this.node; + + if (this.getStateLevel() >= stateLevels.FINISHING) { + node.warn('Game.gameover called on a finishing game.'); + return; + } + + node.emit('GAME_ALMOST_OVER'); + + // Call gameover callback, if it exists. + onGameover = this.plot.stager.getOnGameover(); + if (onGameover) { + this.setStateLevel(stateLevels.FINISHING); + onGameover.call(node.game); + } + + this.setStateLevel(stateLevels.GAMEOVER); + this.setStageLevel(stageLevels.DONE); + + node.log('game over.'); + node.emit('GAME_OVER'); + }; + + /** + * ### Game.isPaused + * + * Returns TRUE, if game is paused + * + * @see Game.pause + */ + Game.prototype.isPaused = function() { + return this.paused; + }; + + /** + * ### Game.pause + * + * Sets the game to pause + * + * @param {string} param Optional. A parameter to pass along the + * emitted events PAUSING and PAUSED. + * + * @see Game.resume + */ + Game.prototype.pause = function(param) { + var msgHandler, node; + + if (!this.isPausable()) { + throw new Error('Game.pause: game cannot be paused.'); + } + + node = this.node; + node.emit('PAUSING', param); + + this.paused = true; + this.pauseCounter++; + + // If the Stager has a method for accepting messages during a + // pause, pass them to it. Otherwise, buffer the messages + // until the game is resumed. + msgHandler = this.plot.getProperty(this.getCurrentGameStage(), + 'pauseMsgHandler'); + if (msgHandler) { + node.socket.setMsgListener(function(msg) { + msg = node.socket.secureParse(msg); + msgHandler.call(node.game, msg.toInEvent(), msg); + }); + } + + node.timer.setTimestamp('paused'); + node.emit('PAUSED', param); + + // TODO: broadcast? + + node.log('game paused.'); + }; + + /** + * ### Game.resume + * + * Resumes the game from pause + * + * @param {string} param Optional. A parameter to pass along the + * emitted events RESUMING and RESUMED. + * + * @see Game.pause + */ + Game.prototype.resume = function(param) { + var msgHandler, node; + + if (!this.isResumable()) { + throw new Error('Game.resume: game cannot be resumed.'); + } + + node = this.node; + + node.emit('RESUMING', param); + + this.paused = false; + + // If the Stager defines an appropriate handler, give it the messages + // that were buffered during the pause. + // Otherwise, emit the buffered messages normally. + msgHandler = this.plot.getProperty(this.getCurrentGameStage(), + 'resumeMsgHandler'); + + node.socket.clearBuffer(msgHandler); + + // Reset the Socket's message handler to the default: + node.socket.setMsgListener(); + node.timer.setTimestamp('resumed'); + node.emit('RESUMED', param); + + // TODO: broadcast? + + // Maybe the game was LOADED during the pausing. + // In this case the PLAYING event got lost. + if (this.shouldEmitPlaying()) { + this.node.emit('PLAYING'); + } + + node.log('game resumed.'); + }; + + /** + * ### Game.shouldStep + * + * Checks if the next step can be executed + * + * The game can step forward if: + * + * - There is the "right" number of players. + * - The game has been initialized, and is not in GAME_OVER. + * - The stepRule function for current step and returns TRUE. + * + * @param {number} stageLevel Optional. If set, it is used instead + * of `Game.getStageLevel()` + * + * @return {boolean} TRUE, if stepping is allowed. + * + * @see Game.step + * @see SizeManager.checkSize + * @see stepRules + */ + Game.prototype.shouldStep = function(stageLevel) { + var stepRule, curStep; + + if (!this.sizeManager.checkSize() || !this.isSteppable()) return false; + + curStep = this.getCurrentGameStage(); + stepRule = this.plot.getStepRule(curStep); + + if ('function' !== typeof stepRule) { + throw new TypeError('Game.shouldStep: stepRule must be function. ' + + 'Found: ' + stepRule); + } + + stageLevel = stageLevel || this.getStageLevel(); + return stepRule(curStep, stageLevel, this.pl, this); + }; + + /** + * ### Game.breakStage + * + * Sets/Removes a flag to break current stage + * + * If the flag is set, when node.done() is invoked, the game will + * step into the next stage instead of into the next step. + * + * @param {boolean} doBreak Optional. TRUE to set the flag, FALSE to + * remove it, or undefined to just get returned the current value. + * + * @return {boolean} The value of the flag before it is overwritten + * by current call. + * + * @see Game._breakStage + * @see Game.gotoStep + */ + Game.prototype.breakStage = function(doBreak) { + var b; + b = this._breakStage; + if ('undefined' !== typeof doBreak) this._breakStage = !!doBreak; + return b; + }; + + /** + * ### Game.stepBack + * + * Executes the previous stage / step + * + * Important! This function should be used only with the appropriate + * syncStepping settings and step rules. For more info see: + * + * https://github.com/nodeGame/nodegame/wiki/BackButton-Widget-v5 + * + * @param {object} options Optional. Options passed to + * `getPreviousStep` and later `gotoStep` + * + * @return {boolean} FALSE, if the execution encountered an error + * + * @see Game.getPreviousStep + * @see Game.gotoStep + */ + Game.prototype.stepBack = function(options) { + var prevStep; + prevStep = this.getPreviousStep(1, options); + if (!prevStep) return false; + // Update the array of stepped steps before we go back + // so that game.getPreviousStep() keeps working correctly. + // We need to remove current step, as well as previous, which is + // about to be re-added. + this._steppedSteps.splice(this._steppedSteps.length - 2, 2); + return this.gotoStep(prevStep, options); + }; + + /** + * ### Game.step + * + * Executes the next stage / step + * + * @param {object} options Optional. Options passed to `gotoStep` + * + * @return {boolean} FALSE, if the execution encountered an error + * + * @see Game.stager + * @see Game.currentStage + * @see Game.gotoStep + * @see Game.execStep + * @see Game.breakStage + */ + Game.prototype.step = function(options) { + var curStep, nextStep; + curStep = this.getCurrentGameStage(); + // Gets current value and sets breakStage flag in one call. + if (this.breakStage(false)) nextStep = this.plot.nextStage(curStep); + else nextStep = this.plot.next(curStep); + return this.gotoStep(nextStep, options); + }; + + /** + * ### Game.gotoStep + * + * Updates the current game step to toStep and executes it. + * + * It unloads the old step listeners, before loading the listeners of the + * new one. + * + * It does note check if the next step is different from the current one, + * and in this case the same step is re-executed. + * + * @param {string|GameStage} nextStep A game stage object, or a string like + * GAME_OVER. + * @param {object} options Optional. Additional options, such as: + * `willBeDone` (immediately calls `node.done()`, useful + * for reconnections) + * + * @return {boolean|null} TRUE, if the step is found and it is executed; + * FALSE, if the step is not found or can't be executed; NULL, if + * we reached the end of the game sequence or it is game over. + * + * @see Game.execStep + * @see PushManager.clearTimer + * @see MatcherManager.match + * + * @emit STEPPING + */ + Game.prototype.gotoStep = function(nextStep, options) { + var node, tmp; + + // Steps references. + var curStep, curStageObj, nextStepObj, nextStageObj; + + // Flags that we need to execute the stage init function. + var stageInit; + + // Step init callback. + var stepInitCb; + + // Variable related to matching roles and partners. + var matcherOptions, matches, role, partner; + var i, len, pid; + + // Sent to every client (if syncStepping and if necessary). + var remoteOptions; + + // Value of exit cb for a step. + var curStepExitCb; + + if (!this.isSteppable()) { + throw new Error('Game.gotoStep: game cannot be stepped'); + } + + if ('string' !== typeof nextStep && 'object' !== typeof nextStep) { + throw new TypeError('Game.gotoStep: nextStep must be ' + + 'a object or a string. Found: ' + nextStep); + } + + if (options && 'object' !== typeof options) { + throw new TypeError('Game.gotoStep: options must be object or ' + + 'undefined. Found: ' + options); + } + + node = this.node; + + node.silly('Next step ---> ' + nextStep); + + // TODO: even if node.game.timer.syncWithStage is on, + // node.done() is not called on logics. So the timer + // is not stopped. We do it manually here for the moment, + // and we clear also the milliseconds count. + this.timer.reset(); + + // Clear push-timer. + this.pushManager.clearTimer(); + + curStep = this.getCurrentGameStage(); + curStageObj = this.plot.getStage(curStep); + // We need to call getProperty because getStep does not mixin tmpCache. + // We do not lookup into the stage. + curStepExitCb = this.plot.getProperty(curStep, 'exit', + null, { stage: true }); + + // Clear the cache of temporary changes to steps. + this.plot.tmpCache.clear(); + + // By default socket journal is off and cleared. + // Need to do it before setup messages are send to clients. + if (node.socket.journalOn) { + node.socket.journalOn = false; + node.socket.journal.clear(); + } + + // Sends start / step command to connected clients if option is on. + if (this.plot.getProperty(nextStep, 'syncStepping')) { + + matcherOptions = this.plot.getProperty(nextStep, 'matcher'); + + if (matcherOptions && 'object' === typeof matcherOptions) { + + // matches = [ + // { + // id: 'playerId', + // options: { + // role: "A", // Optional. + // partner: "XXX", // Optional. + // } + // }, + // ... + // ]; + // + matches = this.matcher.match(matcherOptions); + i = -1, len = matches.length; + for ( ; ++i < len ; ) { + pid = matches[i].id; + // TODO: Allow a more general modification of plot obj + // in remote clients via a new callback, e.g. remoteOptions. + remoteOptions = { plot: matches[i].options }; + + if (curStep.stage === 0) { + node.remoteCommand('start', pid, { + stepOptions: remoteOptions + }); + } + else { + remoteOptions.targetStep = nextStep; + node.remoteCommand('goto_step', pid, remoteOptions); + } + } + } + else { + + if (true === matcherOptions) { + remoteOptions = { plot: { role: true, partner: true }}; + } + + if (curStep.stage === 0) { + // Note: Game.start looks for the stepOptions property + // and passes it Game.step. + node.remoteCommand('start', 'ROOM', { + stepOptions: remoteOptions + }); + } + else { + // Note: 'goto_step' listeners extract the targetStep + // property from object if payload is not the targetStep + // itself (string|GameStage). + if (!remoteOptions) remoteOptions = nextStep; + else remoteOptions.targetStep = nextStep; + node.remoteCommand('goto_step', 'ROOM', remoteOptions); + } + + // this.matcher.clear(); + } + } + + // Calling exit function of the step. + if (curStepExitCb) { + this.setStateLevel(stateLevels.STEP_EXIT); + this.setStageLevel(stageLevels.EXITING); + + curStepExitCb.call(this); + } + + // Listeners from previous step are cleared (must be done after exit). + node.events.ee.step.clear(); + + // Emit buffered messages. + if (node.socket.shouldClearBuffer()) { + node.socket.clearBuffer(); + } + + // Destroy timers created in current step. + node.timer.destroyStepTimers(); + + // String STEP. + + if ('string' === typeof nextStep) { + + // TODO: see if we can avoid code duplication below. + // Calling exit function of the stage. + // Note: stage.exit is not inherited. + if (curStageObj && curStageObj.exit) { + this.setStateLevel(stateLevels.STAGE_EXIT); + this.setStageLevel(stageLevels.EXITING); + + curStageObj.exit.call(this); + } + // Clear any event listeners added in the stage exit function. + node.events.ee.stage.clear(); + + if (nextStep === GamePlot.GAMEOVER) { + this.gameover(); + // Emit buffered messages: + if (node.socket.shouldClearBuffer()) { + node.socket.clearBuffer(); + } + return null; + } + // Was: + // else do nothing + // return null; + else { + // Try to resolve game stage. + tmp = this.plot.normalizeGameStage(nextStep); + if (!nextStep) { + throw new Error('Game.gotoStep: could not resolve step: ' + + nextStep); + } + nextStep = tmp; + tmp = null; + } + } + + // Here we start processing the new STEP. + + // TODO maybe update also in case of string. + node.emit('STEPPING', curStep, nextStep); + + // Check for stage/step existence: + nextStageObj = this.plot.getStage(nextStep); + if (!nextStageObj) return false; + nextStepObj = this.plot.getStep(nextStep); + if (!nextStepObj) return false; + + // If we enter a new stage we need to update a few things. + if (!curStageObj || nextStageObj.id !== curStageObj.id) { + + // Calling exit function. + // Note: stage.exit is not inherited. + if (curStageObj && curStageObj.exit) { + this.setStateLevel(stateLevels.STAGE_EXIT); + this.setStageLevel(stageLevels.EXITING); + + curStageObj.exit.call(this); + } + + // Destroy timers created in current stage. + node.timer.destroyStageTimers(); + + // Mark stage init. + stageInit = true; + } + + // stageLevel needs to be changed (silent), otherwise it stays + // DONE for a short time in the new game stage: + this.setStageLevel(stageLevels.UNINITIALIZED, 'S'); + this.setCurrentGameStage(nextStep); + + // Process options before calling any init function. Sets a role also. + if ('object' === typeof options) { + processGotoStepOptions(this, options); + } + else if (options) { + throw new TypeError('Game.gotoStep: options must be object ' + + 'or undefined. Found: ' + options); + } + + // Properties `role` and `partner` might have been specified + // in the options, processed by processGotoStepOptions and + // inserted in the plot, or be already in the plot. + role = this.plot.getProperty(nextStep, 'role'); + + if (role === true) { + role = this.role; + if (!role) { + throw new Error('Game.gotoStep: "role" is true, but no ' + + 'previous role is found in step ' + nextStep); + } + } + else { + if (!role) role = null; + else if ('function' === typeof role) role = role.call(this); + + if (role === null && this.getProperty('roles') !== null) { + throw new Error('Game.gotoStep: "role" is null, but "roles" ' + + 'are found in step ' + nextStep); + } + } + // Overwrites step properties if a role is set. + this.setRole(role, true); + + partner = this.plot.getProperty(nextStep, 'partner'); + if (!partner) partner = null; + else if (partner === true) partner = this.partner; + else if ('function' === typeof partner) partner = partner.call(this); + this.setPartner(partner, true); + + if (stageInit) { + // Store time. + node.timer.setTimestamp('stage', (new Date()).getTime()); + + // Clear the previous stage listeners. + node.events.ee.stage.clear(); + + this.setStateLevel(stateLevels.STAGE_INIT); + this.setStageLevel(stageLevels.INITIALIZING); + + // Execute the init function of the stage, if any: + // Note: this property is not inherited. + if (nextStageObj.hasOwnProperty('init')) { + nextStageObj.init.call(node.game); + } + } + + // Important! Cannot use: nextStepObj.init because + // a role might have changed the init function, or + // there might be a default property (setDefaultProperty). + // We are the skipping the stage.init property. + stepInitCb = this.plot.getProperty(nextStep, 'init', + null, { stage: true }); + + // Execute the init function of the step, if any. + if (stepInitCb) { + this.setStateLevel(stateLevels.STEP_INIT); + this.setStageLevel(stageLevels.INITIALIZING); + stepInitCb.call(node.game); + } + + this.setStateLevel(stateLevels.PLAYING_STEP); + this.setStageLevel(stageLevels.INITIALIZED); + + // Updating the globals object. + this.updateGlobals(nextStep); + + // Reads Min/Max/Exact Players properties. + this.sizeManager.init(nextStep); + + // Emit buffered messages. + if (node.socket.shouldClearBuffer()) node.socket.clearBuffer(); + + // Update list of stepped steps. + this._steppedSteps.push(nextStep); + + // TODO: check if here is right place, or better in execStep. + // If reconnect is TRUE we save a copy of all messages sent to clients. + // Note: the journal is active only if Game.isReady is true. + if (this.plot.getProperty(nextStep, 'reconnect') === true) { + node.socket.journalOn = true; + } + + // If we should be done now, we emit PLAYING without executing the step. + // node.game.willBeDone is already set, and will trigger node.done(). + if (this.beDone) node.emit('PLAYING'); + else this.execStep(this.getCurrentGameStage()); + + return true; + }; + + /** + * ### Game.execStep + * + * Executes the specified stage object + * + * @param {GameStage} step Step to execute + */ + Game.prototype.execStep = function(step) { + var cb, origCb; + var widget, widgetObj, widgetRoot; + var widgetCb, widgetExit, widgetDone; + var doneCb, origDoneCb, exitCb, origExitCb; + var w, frame, uri, frameOptions, frameAutoParse, reloadFrame; + + if ('object' !== typeof step) { + throw new TypeError('Game.execStep: step must be object. Found: ' + + step); + } + + cb = this.plot.getProperty(step, 'cb'); + frame = this.plot.getProperty(step, 'frame'); + widget = this.plot.getProperty(step, 'widget'); + + if (widget) { + // Mark that it is a widget step. + this.widgetStep = true; + + // Parse input params. // TODO: throws errors. + if ('string' === typeof widget) widget = { name: widget }; + if ('string' !== typeof widget.id) { + widget.id = 'ng_step_widget_' + widget.name; + } + if (!widget.ref) { + widget.ref = widget.name.toLowerCase(); + // Make sure it is unique. + if (this[widget.ref]) { + widget.ref = J.uniqueKey(this, widget.ref); + } + } + + // Add options, if missing. + // User can specify the options in a nested object, or flat them + // down in case there are no conflicts. + if (!widget.options) widget.options = widget; + + // Make main callback to get/append the widget. + widgetCb = function() { + + if (widget.append === false) { + widgetObj = this.node.widgets.get(widget.name, + widget.options); + } + else { + // Default class. + if (!widget.options.className) { + widget.options.className = 'centered'; + } + widget.options.widgetStep = true; + + // Default id 'container' (as in default.html). + if ('string' === typeof widget.root) { + widgetRoot = widget.root; + } + else if ('undefined' !== typeof widget.root) { + throw new TypeError('Game.execStep: widget.root must ' + + 'be string or undefined. Found: ' + + widget.root); + } + else { + widgetRoot = 'container'; + } + // If widgetRoot is not existing, it follows the + // default procedure for appending a widget. + widgetRoot = W.getElementById(widgetRoot); + widgetObj = this.node.widgets.append(widget.name, + widgetRoot, + widget.options); + } + node.game[widget.ref] = widgetObj; + }; + + // Make the step callback. + // Notice: This works with roles also. + if (cb) { + origCb = cb; + cb = function() { + widgetCb.call(this); + origCb.call(this); + }; + } + else { + cb = widgetCb; + } + + // Make the done callback to send results. + widgetDone = function() { + var values, opts, req; + req = widgetObj.required || widgetObj.requiredChoice; + // TODO: harmonize: required or checkValues? + if (req && widget.checkValues !== false) { + opts = { highlight: true, markAttempt: true }; + } + else { + opts = { highlight: false, markAttempt: false }; + } + // Under some special conditions (e.g., very fast DONE + // clicking this can be null. TODO: check why. + // Changed from this[widget.ref] to widgetObj. + values = widgetObj.getValues(opts); + + // If it is not timeup, and user did not + // disabled it, check answers. + if (req && widget.checkValues !== false && + !node.game.timer.isTimeup()) { + + // Widget must return some values (otherwise it + // is impossible to check if the values are OK). + if (values && + // TODO: check whether it is fine to comment out + // the checks on missValues. We should rely only + // on isCorrect, but some widgets might be outdated. + // (values.missValues === true || + // (values.missValues && values.missValues.length) || + (values.choice === null || + values.isCorrect === false)) { + + + if (values._scrolledIntoView !== true && + 'function' === typeof + widgetObj.bodyDiv.scrollIntoView) { + + widgetObj.bodyDiv.scrollIntoView({ + behavior: 'smooth' + }); + + // TODO: delete _scrolledIntoView ? + } + + return false; + } + } + + return values; + }; + doneCb = this.plot.getProperty(step, 'done'); + if (doneCb) { + origDoneCb = doneCb; + doneCb = function() { + var values, valuesCb; + values = widgetDone.call(this); + if (values !== false) { + valuesCb = origDoneCb.call(this, values); + // Standard DONE callback behavior (to modify objects). + if ('undefined' !== typeof valuesCb) { + values = valuesCb; + } + } + return values; + }; + } + else { + doneCb = widgetDone; + } + + // Update the exit function for this step. + this.plot.tmpCache('done', doneCb); + + // Make the exit callback (destroy widget by default). + if (widget.destroyOnExit !== false) { + widgetExit = function() { + // It can happen with a gotoStep remote command. + if (!node.game[widget.ref]) return; + node.game[widget.ref].destroy(); + // Remove node.game reference. + node.game[widget.ref] = null; + }; + // We are skipping the stage.exit property. + exitCb = this.plot.getProperty(step, 'exit', + null, { stage: true }); + if (exitCb) { + origExitCb = exitCb; + exitCb = function() { + widgetExit.call(this); + origExitCb.call(this); + }; + } + else { + exitCb = widgetExit; + } + // Update the exit function for this step. + this.plot.tmpCache('exit', exitCb); + } + + // Sets a default frame, if none was found. + if (widget.append !== false && !frame) { + frame = '/pages/default.html'; + } + } + else { + this.widgetStep = false; + } + + w = this.node.window; + // Handle frame loading natively, if required. + if (frame) { + frameOptions = {}; + if ('function' === typeof frame) frame = frame.call(node.game); + if ('string' === typeof frame) { + uri = frame; + } + else if ('object' === typeof frame) { + uri = frame.uri; + if ('string' !== typeof uri) { + throw new TypeError('Game.execStep: frame.uri must ' + + 'be string: ' + uri + '. ' + + 'Step: ' + step); + } + frameOptions.frameLoadMode = frame.loadMode; + frameOptions.storeMode = frame.storeMode; + frameAutoParse = frame.autoParse; + if (frameAutoParse) { + // Replacing TRUE with node.game.settings. + if (frameAutoParse === true) { + frameAutoParse = this.settings; + } + + frameOptions.autoParse = frameAutoParse; + frameOptions.autoParseMod = frame.autoParseMod; + frameOptions.autoParsePrefix = frame.autoParsePrefix; + } + } + else { + throw new TypeError('Game.execStep: frame must be string or ' + + 'object. Found: ' + frame + '. ' + + 'Step: ' + step); + + } + + if (w) reloadFrame = uri !== w.unprocessedUri; + // We reload the frame if (order matters): + // - it is a different uri from previous step, + // - unless frameOptions.reload is false, + // - or it is a different stage or round. + if (!reloadFrame) { + if ('undefined' !== typeof frame.reload) { + reloadFrame = !!frame.reload; + } + else { + // Get the previously played step + // (-2, because current step is already inserted). + reloadFrame = + this._steppedSteps[this._steppedSteps.length-2]; + if (reloadFrame) { + reloadFrame = (reloadFrame.round !== step.round || + reloadFrame.stage !== step.stage); + } + else { + reloadFrame = true; + } + } + } + + if (reloadFrame) { + // Auto load frame and wrap cb. + this.execCallback(function() { + this.node.window.loadFrame(uri, cb, frameOptions); + }); + } + else { + // Duplicated as below. + this.execCallback(cb); + if (w) { + w.adjustFrameHeight(0, 120); + if (frame.scrollUp !== false) window.scrollTo(0,0); + } + } + } + else { + // Duplicated as above. + this.execCallback(cb); + if (w) { + w.adjustFrameHeight(0, 120); + window.scrollTo(0, 0); + } + } + }; + + /** + * ### Game.execCallback + * + * Executes a game callback + * + * Sets the stage levels before and after executing the callback, + * and emits an event before exiting. + * + * @param {function} cb The callback to execute + * + * @return {mixed} res The return value of the callback + * + * @emit 'STEP_CALLBACK_EXECUTED' + */ + Game.prototype.execCallback = function(cb) { + var res; + this.setStageLevel(stageLevels.EXECUTING_CALLBACK); + + // Execute custom callback. Can throw errors. + res = cb.call(this.node.game); + if (res === false) { + // A non fatal error occurred. + this.node.err('A non fatal error occurred in callback ' + + 'of stage ' + this.getCurrentGameStage()); + } + + this.setStageLevel(stageLevels.CALLBACK_EXECUTED); + this.node.emit('STEP_CALLBACK_EXECUTED'); + // Internal listeners will check whether we need to emit PLAYING. + }; + + /** + * ### Game.getCurrentStepObj + * + * Returns the object representing the current game step. + * + * The returning object includes all the properties, such as: + * _id_, _cb_, _timer_, etc. + * + * @return {object} The game-step as defined in the stager. + * + * @see Stager + * @see GamePlot + */ + Game.prototype.getCurrentStepObj = function() { + return this.plot.getStep(this.getCurrentGameStage()); + }; + + /** + * ### Game.getCurrentStep + * + * Alias for Game.prototype.getCurrentStepObj + * + * @deprecated + */ + Game.prototype.getCurrentStep = Game.prototype.getCurrentStepObj; + + /** + * ### Game.getCurrentStageObj + * + * Returns the object representing the current game stage. + * + * The returning object includes all the properties, such as: + * _id_, _init_, etc. + * + * @return {object} The game-stage as defined in the stager. + * + * @see Stager + * @see GamePlot + */ + Game.prototype.getCurrentStageObj = function() { + return this.plot.getStage(this.getCurrentGameStage()); + }; + + /** + * ### Game.getCurrentStepProperty + * + * Returns the object representing the current game step. + * + * The returning object includes all the properties, such as: + * _id_, _cb_, _timer_, etc. + * + * @return {object} The game-step as defined in the stager. + * + * @see Stager + * @see GamePlot + */ + Game.prototype.getCurrentStepProperty = function(propertyName) { + var step; + if ('string' !== typeof propertyName) { + throw new TypeError('Game.getCurrentStepProperty: propertyName ' + + 'must be string'); + } + step = this.plot.getStep(this.getCurrentGameStage()); + return 'undefined' === typeof step[propertyName] ? + null : step[propertyName]; + }; + + /** + * ### Game.getCurrentGameStage + * + * Returns the GameStage that is currently being executed. + * + * @param {boolean} clone If TRUE, the GameStage is cloned, otherwise a + * reference is returned. + * + * @return {GameStage} The stage currently played. + * + * @see node.player.stage + */ + Game.prototype.getCurrentGameStage = function(clone) { + var s = this.node.player.stage; + return clone ? + new GameStage({ stage: s.stage, step: s.step, round: s.round }) : s; + }; + + /** + * ### Game.setCurrentGameStage + * + * Sets the current game stage and notifies the server + * + * Stores the value of current game stage in `node.player.stage`. + * + * By default, it does not send the update to the server if the + * new stage is the same as the previous one. However, it is + * possible to override this behavior with specyfing a second + * parameter `mod`. + * + * @param {string|GameStage} gameStage The value of the update. + * For example, an object, or a string like '1.1.1'. + * @param {string} mod Optional. A string modifiying the default + * behavior ('F' = force, 'S' = silent'). + * + * @see Game.publishUpdate + */ + Game.prototype.setCurrentGameStage = function(gameStage, mod) { + gameStage = new GameStage(gameStage); + if (mod === 'F' || + (!mod && GameStage.compare(this.getCurrentGameStage(), + gameStage) !== 0)) { + + // Important: First publish, then actually update. + // The stage level, must also be sent in the published update, + // otherwise we could have a mismatch in the remote + // representation of the stage + stageLevel of the client. + this.publishUpdate('stage', { + stage: gameStage, + stageLevel: this.getStageLevel() + }); + } + + this.node.player.stage = gameStage; + }; + + /** + * ### Game.getStateLevel + * + * Returns the state of the nodeGame engine + * + * The engine states are defined in `node.stateLevels`, + * and it is of the type: STAGE_INIT, PLAYING_STEP, GAMEOVER, etc. + * The return value is a reference to `node.player.stateLevel`. + * + * @return {number} The state of the engine. + * @see node.player.stateLevel + * @see node.stateLevels + */ + Game.prototype.getStateLevel = function() { + return this.node.player.stateLevel; + }; + + /** + * ### Game.setStateLevel + * + * Sets the current game state level, and optionally notifies the server + * + * The value is actually stored in `node.player.stateLevel`. + * + * Stage levels are defined in `node.stageLevels`, for example: + * STAGE_INIT, PLAYING_STEP, GAMEOVER, etc. + * + * By default, it does not send the update to the server if the + * new state level is the same as the previous one. However, it is + * possible to override this behavior with specyfing a second + * parameter `mod`. + * + * @param {number} stateLevel The value of the update. + * @param {string} mod Optional. A string modifiying the default + * behavior ('F' = force, 'S' = silent'). + * + * @see Game.publishUpdate + * @see node.stageLevels + */ + Game.prototype.setStateLevel = function(stateLevel, mod) { + var node; + node = this.node; + if ('number' !== typeof stateLevel) { + throw new TypeError('Game.setStateLevel: stateLevel must be ' + + 'number. Found: ' + stateLevel); + } + // Important: First publish, then actually update. + if (mod === 'F' || (!mod && this.getStateLevel() !== stateLevel)) { + this.publishUpdate('stateLevel', { + stateLevel: stateLevel + }); + } + node.player.stateLevel = stateLevel; + }; + + /** + * ### Game.getStageLevel + * + * Return the execution level of the current game stage + * + * The execution level is defined in `node.stageLevels`, + * and it is of the type INITIALIZED, CALLBACK_EXECUTED, etc. + * The return value is a reference to `node.player.stageLevel`. + * + * @return {number} The level of the stage execution. + * @see node.player.stageLevel + * @see node.stageLevels + */ + Game.prototype.getStageLevel = function() { + return this.node.player.stageLevel; + }; + + /** + * ### Game.setStageLevel + * + * Sets the current game stage level, and optionally notifies the server + * + * The value is actually stored in `node.player.stageLevel`. + * + * Stage levels are defined in `node.stageLevels`, for example: + * PLAYING, DONE, etc. + * + * By default, it does not send the update to the server if the + * new state level is the same as the previous one. However, it is + * possible to override this behavior with specyfing a second + * parameter `mod`. + * + * @param {string|GameStage} gameStage The value of the update. + * @param {string} mod Optional. A string modifiying the default + * behavior ('F' = force, 'S' = silent'). + * + * @see Game.publishUpdate + * @see node.stageLevels + */ + Game.prototype.setStageLevel = function(stageLevel, mod) { + var node; + node = this.node; + if ('number' !== typeof stageLevel) { + throw new TypeError('Game.setStageLevel: stageLevel must be ' + + 'number. Found: ' + stageLevel); + } + // Important: First publish, then actually update. + if (mod === 'F' || (!mod && this.getStageLevel() !== stageLevel)) { + this.publishUpdate('stageLevel', { + stageLevel: stageLevel + }); + } + node.player.stageLevel = stageLevel; + }; + + /** + * ### Game.publishUpdate + * + * Sends out a PLAYER_UPDATE message, if conditions are met. + * + * Type is a property of the `node.player` object. + * + * @param {string} type The type of update: + * 'stateLevel', 'stageLevel', 'gameStage'. + * @param {mixed} newValue Optional. The actual value of update to be sent. + * + * @see Game.shouldPublishUpdate + */ + Game.prototype.publishUpdate = function(type, update) { + if ('string' !== typeof type) { + throw new TypeError('Game.publishUpdate: type must be string. ' + + 'Found: ' + type); + } + if (type !== 'stage' && + type !== 'stageLevel' && + type !== 'stateLevel') { + + throw new Error('Game.publishUpdate: unknown update type: ' + type); + } + if (this.shouldPublishUpdate(type, update)) { + this.node.socket.send(this.node.msg.create({ + target: constants.target.PLAYER_UPDATE, + data: update, + text: type, + to: 'ROOM' + })); + } + }; + + /** + * ### Game.shouldPublishUpdate + * + * Checks whether a game update should be sent to the server + * + * Evaluates the current `publishLevel`, the type of update, and the + * value of the update to decide whether is to be published or not. + * + * Checks also if the `syncOnLoaded` option is on. + * + * Updates rules are described in '/lib/modules/variables.js'. + * + * @param {string} type The type of update: + * 'stateLevel', 'stageLevel', 'gameStage'. + * @param {mixed} value Optional. The actual update to be sent + * + * @return {boolean} TRUE, if the update should be sent + */ + Game.prototype.shouldPublishUpdate = function(type, value) { + var myStage, levels, myPublishLevel; + if ('string' !== typeof type) { + throw new TypeError( + 'Game.shouldPublishUpdate: type must be string.'); + } + + myStage = this.getCurrentGameStage(); + levels = constants.publishLevels; + + myPublishLevel = this.plot.getProperty(myStage, 'publishLevel'); + + // Two cases are handled outside of the switch: NO msg + // and LOADED stage with syncOnLoaded option. + if (myPublishLevel === levels.NONE) { + return false; + } + if (this.plot.getProperty(myStage, 'syncOnLoaded')) { + if (type === 'stageLevel' && + value.stageLevel === stageLevels.LOADED) { + return true; + } + // Else will be evaluated below. + } + + // Check all the other cases. + switch(myPublishLevel) { + case levels.FEW: + return type === 'stage'; + case levels.REGULAR: + if (type === 'stateLevel') return false; + if (type === 'stageLevel') { + return (value.stageLevel === stageLevels.PLAYING || + value.stageLevel === stageLevels.DONE); + } + return true; // type === 'stage' + case levels.MOST: + return type !== 'stateLevel'; + case levels.ALL: + return true; + default: + // Unknown values of publishLevels are treated as ALL. + return true; + } + }; + + /** + * ### Game.isReady + * + * Returns TRUE if a game is set and interactive + * + * A game is ready unless a stage or step is currently being + * loaded or a DONE procedure has been started, i.e. between the + * stage levels: PLAYING and GETTING_DONE. + * + * If a game is paused, it is also NOT ready. + * + * @see node.stageLevels + */ + Game.prototype.isReady = function() { + var stageLevel, stateLevel; + + if (this.paused) return false; + + stateLevel = this.getStateLevel(); + + switch (stateLevel) { + case stateLevels.UNINITIALIZED: + case stateLevels.INITIALIZING: + case stateLevels.STAGE_INIT: + case stateLevels.STEP_INIT: + case stateLevels.FINISHING: + case stateLevels.STAGE_EXIT: + case stateLevels.STEP_EXIT: + return false; + + case stateLevels.PLAYING_STEP: + + stageLevel = this.getStageLevel(); + switch (stageLevel) { + case stageLevels.EXECUTING_CALLBACK: + case stageLevels.CALLBACK_EXECUTED: + case stageLevels.PAUSING: + case stageLevels.RESUMING: + case stageLevels.GETTING_DONE: + // TODO: should this be commented? See issue #168 + // case stageLevels.DONE: + return false; + } + break; + } + return true; + }; + + /** + * ### Game.isStartable + * + * Returns TRUE if Game.start can be called + * + * @return {boolean} TRUE if the game can be started. + */ + Game.prototype.isStartable = function() { + return this.plot.isReady() && + this.getStateLevel() < stateLevels.INITIALIZING; + }; + + + /** + * ### Game.isStoppable + * + * Returns TRUE if Game.stop can be called + * + * @return {boolean} TRUE if the game can be stopped. + */ + Game.prototype.isStoppable = function() { + return this.getStateLevel() > stateLevels.INITIALIZING; + }; + + + /** + * ### Game.isPausable + * + * Returns TRUE if Game.pause can be called + * + * @return {boolean} TRUE if the game can be paused. + */ + Game.prototype.isPausable = function() { + return !this.paused && + this.getStateLevel() > stateLevels.INITIALIZING; + }; + + + /** + * ### Game.isResumable + * + * Returns TRUE if Game.resume can be called + * + * @return {boolean} TRUE if the game can be resumed. + */ + Game.prototype.isResumable = function() { + return this.paused && + this.getStateLevel() > stateLevels.INITIALIZING; + }; + + + /** + * ### Game.isSteppable + * + * Returns TRUE if Game.step and Game.gotoStep can be called + * + * @return {boolean} TRUE if the game can be stepped. + */ + Game.prototype.isSteppable = function() { + var stateLevel; + stateLevel = this.getStateLevel(); + + return stateLevel > stateLevels.INITIALIZING && + stateLevel < stateLevels.FINISHING; + }; + + /** + * ### Game.isGameover + * + * Returns TRUE if gameover was called and state level set + * + * @return {boolean} TRUE if is game over + */ + Game.prototype.isGameover = Game.prototype.isGameOver = function() { + return this.getStateLevel() === stateLevels.GAMEOVER; + }; + + /** + * ### Game.shouldEmitPlaying + * + * Gives the last green light to let the players play a step. + * + * Sometimes we want to synchronize players to the very last + * moment before they start playing. Here we check again. + * This handles the case also if some players has disconnected + * between the beginning of the stepping procedure and this + * method call. + * + * Checks also the GameWindow object. + * + * @param {boolean} strict If TRUE, PLAYING can be emitted only coming + * from the LOADED stage level. Default: TRUE + * + * @return {boolean} TRUE, if the PLAYING event should be emitted. + * + * @see SizeManager.checkSize + */ + Game.prototype.shouldEmitPlaying = function(strict) { + var curGameStage, curStageLevel, syncOnLoaded, node; + if ('undefined' === typeof strict || strict) { + // Should emit PLAYING only after LOADED. + curStageLevel = this.getStageLevel(); + if (curStageLevel !== stageLevels.LOADED) return false; + } + node = this.node; + curGameStage = this.getCurrentGameStage(); + if (!this.isReady()) return false; + if (!this.sizeManager.checkSize()) return false; + + // `syncOnLoaded` forces clients to wait for all the others to be + // fully loaded before releasing the control of the screen to the + // players. This introduces a little overhead in + // communications and delay in the execution of a stage. It is + // not necessary in local networks, and it is FALSE by default. + syncOnLoaded = this.plot.getProperty(curGameStage, 'syncOnLoaded'); + if (!syncOnLoaded) return true; + return node.game.pl.isStepLoaded(curGameStage); + }; + + /** + * ### Game.compareCurrentStep + * + * Returns the relative order of a step with the current step + * + * @param {GameStage|string} step The step to compare + * + * @return {number} 0 if comparing step is the same as current step, + * -1 if current step is before comparing step, 1 if current step + * is after comparing step + */ + Game.prototype.compareCurrentStep = function(step) { + var normalizedStep; + normalizedStep = this.plot.normalizeGameStage(new GameStage(step)); + return GameStage.compare(this.getCurrentGameStage(), normalizedStep); + }; + + /** + * ### Game.getPreviousStep + * + * Returns the game-stage played delta steps ago + * + * @param {number} delta Optional. The number of past steps. Default 1 + * @param {bolean|object} opts Optional. A configuration object accepting + * the following options: + * + * - acrossStages: if FALSE, if the previous step belongs to another + * stage, it returns NULL. Default: TRUE. + * - acrossRounds: if FALSE, if the previous step belongs to another + * round, it returns NULL. Default: TRUE. + * - noZeroStep: if TRUE, replaces return value 0.0.0 with NULL. + * Default: FALSE. + * - execLoops If TRUE, loop and doLoop conditional functions are + * executed to determine the previous step. If FALSE, if a loop + * or doLoop is found, it returns NULL. Note! This option is + * evaluated only if no step is found in the cache. Default: TRUE + * + * Note: for backward compatibility, if this parameter is a boolean, + * it will be treated as option execLoops. + * + * @return {GameStage|null} The game-stage played delta steps ago, + * null if an error occurred (e.g., a loop stage), or stage 0.0.0 for + * all deltas > steppable steps (i.e., previous of 0.0.0 is 0.0.0). + * + * @see Game._steppedSteps + * @see GamePlot.jump + */ + Game.prototype.getPreviousStep = function(delta, opts) { + var len, curStep, prevStep, execLoops; + delta = delta || 1; + if ('number' !== typeof delta || delta < 1) { + throw new TypeError('Game.getPreviousStep: delta must be a ' + + 'positive number or undefined. Found: ' + + delta); + } + len = this._steppedSteps.length - delta - 1; + // In position 0 there is 0.0.0, which is added also in case + // of a reconnection. + if (len > 0) { + prevStep = this._steppedSteps[len]; + } + else { + // It is possible that it is a reconnection, so we are missing + // stepped steps. Let's do a deeper lookup. + if ('boolean' === typeof opts) execLoops = opts; + prevStep = this.plot.jump(this.getCurrentGameStage(), + -delta, execLoops); + } + // Additional checks might be needed. + if ('object' === typeof opts) { + curStep = node.game.getCurrentGameStage(); + if (opts.acrossStages === false && + (curStep.stage !== prevStep.stage)) { + + return null; + } + if (opts.acrossRounds === false && + (curStep.round !== prevStep.round)) { + + return null; + } + if (opts.noZeroStep && prevStep.stage === 0) return null; + + } + return prevStep; + // For future reference, why is this complicated: + // - Server could store all stepped steps and send them back + // upon reconnection, but it would miss steps stepped while client + // was disconnected. + // - Server could send all steps stepped by logic, but it would not + // work if syncStepping is disabled. + // TODO: Maybe the sequence of prev steps should be precomputed? + }; + + /** + * ### Game.getNextStep + * + * Returns the game-stage that will be played in delta steps + * + * @param {number} delta Optional. The number of future steps. Default 1 + * + * @return {GameStage|null} The game-stage that will be played in + * delta future steps, or null if none is found, or if the game + * sequence contains a loop in between + */ + Game.prototype.getNextStep = function(delta) { + delta = delta || 1; + if ('number' !== typeof delta || delta < 1) { + throw new TypeError('Game.getNextStep: delta must be a ' + + 'positive number or undefined: ', delta); + } + return this.plot.jump(this.getCurrentGameStage(), delta, false); + }; + + /** + * ### Game.updateGlobals + * + * Updates node.globals and adds properties to window in the browser + * + * @param {GameStage} stage Optional. The reference game stage. + * Default: Game.currentGameStage() + * + * @return Game.globals + */ + Game.prototype.updateGlobals = function(stage) { + var newGlobals, g; + stage = stage || this.getCurrentGameStage(); + newGlobals = this.plot.getGlobals(stage); + if ('undefined' !== typeof window && this.node.window) { + // Adding new globals. + for (g in newGlobals) { + if (newGlobals.hasOwnProperty(g)) { + if (g === 'node' || g === 'W') { + node.warn('Game.updateGlobals: invalid name: ' + g); + } + else { + window[g] = newGlobals[g]; + } + } + } + // Removing old ones. + for (g in this.globals) { + if (this.globals.hasOwnProperty(g) && + !newGlobals.hasOwnProperty(g)) { + if (g !== 'node' || g !== 'W') { + delete window[g]; + } + } + } + } + // Updating globals reference. + this.globals = newGlobals; + return this.globals; + }; + + + /** + * ### Game.getProperty + * + * Returns the requested step property from the game plot + * + * @param {string} property The name of the property + * @param {mixed} nf Optional. The return value in case the + * requested property is not found. Default: null. + * + * @return {mixed} The value of the requested step property + * + * @see GamePlot.getProperty + */ + Game.prototype.getProperty = function(prop, nf) { + return this.plot.getProperty(this.getCurrentGameStage(), prop, nf); + }; + + /** + * ### Game.getStageId + * + * Returns the id of current stage, or of another user-specified stage + * + * @param {object} stage Optional. A GameStage object. Default: current + * game stage. + * + * @return {string|null} The id of (current) stage, or NULL if not found + * + * @see GamePlot.getStage + */ + Game.prototype.getStageId = function(stage) { + stage = this.plot.getStage(stage || this.getCurrentGameStage()); + return stage ? stage.id : null; + }; + + /** + * ### Game.getStepId + * + * Returns the id of current step, or of another user-specified stage + * + * @param {object} stage Optional. A GameStage object. Default: current + * game stage. + * + * @return {string|null} The id of (current) step, or NULL if not found + * + * @see GamePlot.getStage + */ + Game.prototype.getStepId = function(stage) { + stage = this.plot.getStep(stage || this.getCurrentGameStage()); + return stage ? stage.id : null; + }; + + /** + * ### Game.getRound + * + * Returns the current/remaining/past/total round number in current stage + * + * @param {string} mod Optional. Modifies the return value. + * + * - 'current': current round number (default) + * - 'total': total number of rounds + * - 'remaining': number of rounds remaining (excluding current round) + * - 'past': number of rounds already past (excluding current round) + * + * @return {number|null} The requested information, or null if + * the number of rounds is not known (e.g. if the stage is a loop) + * + * @see GamePlot.getRound + */ + Game.prototype.getRound = function(mod) { + return this.plot.getRound(this.getCurrentGameStage(), mod); + }; + + /** + * ### Game.isStage + * + * Returns TRUE if current stage matches input parameter + * + * Steps and rounds aer not considered. + * + * @param {string|GameStage|number} stage The name of the stage, its + * ordinal position in the game sequence, or its object + * representation. If string, the object is resolved + * with GamePlot.normalizeGameStage + * @param {GameStage} compareStage The stage to compare against. + * Default: current game stage. + * + * @return {boolean} TRUE if current stage matches input parameter + * + * @see GamePlot.normalizeGameStage + * @see Game.getCurrentGameStage + */ + Game.prototype.isStage = function(stage, compareStage) { + var s; + if (compareStage) { + if ('object' === typeof compareStage) { + s = compareStage.stage; + } + else { + throw new TypeError('Game.isStage: compareStage must be ' + + 'object or undefined. Found: ' + + compareStage); + } + } + else { + s = this.getCurrentGameStage().stage; + } + if ('number' === typeof stage) return stage === s; + stage = this.plot.normalizeGameStage(stage); + return !!(stage && stage.stage === s); + }; + + /** + * ### Game.isStep + * + * Returns TRUE if current step matches input parameter + * + * Behavior changes depending on type of input parameter: + * + * - number: only the ordinal position in the game stage is matched + * - object|string: the stage and the step are matched + * + * @param {string|GameStage|number} step The name of the step, its + * ordinal position in the game stage, or its object + * representation. If string, the object is resolved + * with GamePlot.normalizeGameStage + * @param {GameStage} compareStage The stage to compare against. + * Default: current game stage. + * + * @return {boolean} TRUE if current step matches input parameter + * + * @see GamePlot.normalizeGameStage + */ + Game.prototype.isStep = function(step, compareStage) { + var s; + if (compareStage) { + if ('object' === typeof compareStage) { + s = compareStage.step; + } + else { + throw new TypeError('Game.isStep: compareStage must be ' + + 'object or undefined. Found: ' + + compareStage); + } + } + else { + s = this.getCurrentGameStage().step; + } + if ('number' === typeof step) return step === s; + // Add the current stage id for normalization if no stage is provided. + if (step.lastIndexOf('.') === -1) { + step = this.getStageId(compareStage) + '.' + step; + } + step = this.plot.normalizeGameStage(step); + return !!(step && step.step === s); + }; + + /** + * ### Game.isRound + * + * Returns TRUE if current step matches input parameter + * + * Behavior changes depending on type of input parameter: + * + * - number: only the ordinal position in the game stage is matched + * - object: the stage and the step are matched + * + * @param {GameStage|number} round The round number or its object + * representation. If object, it is resolved + * with GamePlot.normalizeGameStage + * + * @return {boolean} TRUE if current step matches input parameter + * + * @see GamePlot.normalizeGameStage + */ + Game.prototype.isRound = function(round) { + var r; + r = this.getRound(); + if ('number' === typeof round) return round === r; + round = this.plot.normalizeGameStage(round); + return !!(round && round.round === r); + }; + + /** + * ### Game.isWidgetStep + * + * Returns TRUE if current step is a widget step + * + * @return {boolean} TRUE if current step is a widget step + * + * @see GamePlot.widgetStep + * @see GamePlot.execStep + */ + Game.prototype.isWidgetStep = function() { + return this.widgetStep; + }; + + /** + * ### Game.setRole + * + * Sets the current role in the game + * + * When a role is set, all the properties of a role overwrite + * the current step properties. + * + * Roles are not supposed to be set more than once per step, and + * an error will be thrown on attempts to overwrite roles. + * + * Updates the reference also in `node.player.role`. + * + * @param {string|null} role The name of the role + * @param {boolean} force Optional. If TRUE, role can be overwritten + * + * @see Game.role + * @see Player.role + */ + Game.prototype.setRole = function(role, force) { + var roles, roleObj, prop; + if ('string' === typeof role && role.trim() !== '') { + if (this.role && !force) { + throw new Error('Game.setRole: attempt to change role "' + + this.role + '" to "' + role + '" in step: ' + + this.getCurrentGameStage()); + } + roles = this.getProperty('roles'); + if (!roles) { + throw new Error('Game.setRole: trying to set role "' + + role + '", but \'roles\' not found in ' + + 'current step: ' + + this.getCurrentGameStage()); + } + roleObj = roles[role]; + if (!roleObj) { + throw new Error('Game.setRole: role "' + role + + '" not found in current step: ' + + this.getCurrentGameStage()); + } + + // Modify plot properties. + for (prop in roleObj) { + if (roleObj.hasOwnProperty(prop)) { + this.plot.tmpCache(prop, roleObj[prop]); + } + } + + } + else if (role !== null) { + throw new TypeError('Game.setRole: role must be string or null. ' + + 'Found: ' + role); + } + this.role = role; + this.node.player.role = role; + }; + + /** + * ### Game.getRole + * + * Returns the current role in the game + * + * @see Game.role + * @see Player.role + */ + Game.prototype.getRole = function() { + return this.role; + }; + + /** + * ### Game.setPartner + * + * Sets the current partner in the game + * + * Partners are not supposed to be set more than once per step, and + * an error will be thrown on attempts to overwrite them. + * + * Updates the reference also in `node.player.partner`. + * + * @param {string|null} partner The id or alias of the partner + * @param {boolean} force Optional. If TRUE, partner can be overwritten + * + * @see Game.partner + * @see Player.partner + */ + Game.prototype.setPartner = function(partner, force) { + if ('string' === typeof partner && partner.trim() !== '') { + if (this.partner && !force) { + throw new Error('Game.setPartner: attempt to change partner "' + + this.partner + '" to "' + partner + + '" in step: ' + this.getCurrentGameStage()); + } + } + else if (partner !== null) { + throw new TypeError('Game.setPartner: partner must be a ' + + 'non-empty string or null. Found: ' + partner); + } + this.partner = partner; + this.node.player.partner = partner; + }; + + /** + * ### Game.getPartner + * + * Returns the current partner in the game + * + * @see Game.partner + * @see Player.partner + */ + Game.prototype.getPartner = function() { + return this.partner; + }; + + // ## Helper Methods + + /** + * ### processGoToStepOptions + * + * Process options before executing the init functions of stage/steps + * + * Valid options: + * + * - willBeDone: game will be done after loading the frame and executing + * the step callback function, + * - beDone: game is done without loading the frame or + * executing the step callback function, + * - plot: add entries to the tmpCache of the plot, + * - msgs: incoming messages to emit. + * - cb: a callback executed with the game context, and with options + * object itself as parameter + * + * @param {Game} game The game instance + * @param {object} opts The options to process + * + * @see Game.gotoStep + * @see GamePlot.tmpCache + * @see Game.willBeDone + * @see Game.beDone + */ + function processGotoStepOptions(game, opts) { + var prop, so; + + // Be done.. now! Skips Game.execStep. + if (opts.beDone) { + game.willBeDone = true; + game.beDone = true; + } + else if (opts.willBeDone) { + // TODO: why not setting willBeDone? It was not working, check! + // Call node.done() immediately after PLAYING is emitted. + game.node.once('PLAYING', function() { + game.node.done(); + }); + } + + // Temporarily modify plot properties. + // Must be done after setting the role. + if (opts.plot) { + for (prop in opts.plot) { + if (opts.plot.hasOwnProperty(prop)) { + game.plot.tmpCache(prop, opts.plot[prop]); + } + } + } + + if (opts.msgs) { + opts.msgs.foreach(function(msg) { + game.node.socket.onMessage(new GameMsg(msg).toInEvent(), msg); + }); + } + + // Experimental. To be replaced by a session manager. + // if (opts.game) { + // for (prop in opts.game) { + // if (opts.game.hasOwnProperty(prop)) { + // game[prop] = opts.game[prop]; + // } + // } + // } + + if (opts.session) { + so = { to: false }; + for (prop in opts.session) { + if (opts.session.hasOwnProperty(prop)) { + node.game.session(prop, opts.session[prop], so); + } + } + } + + // TODO: rename cb. + // Call the cb with opts as param, if found. + if (opts.cb) { + if ('function' === typeof opts.cb) { + opts.cb.call(game, opts); + } + else { + throw new TypeError('Game.gotoStep: opts.cb must be ' + + 'function or undefined. Found: ' + + opts.cb); + } + } + } + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # GameSession + * Copyright(c) 2022 Stefano Balietti + * MIT Licensed + * + * `nodeGame` session manager + */ +(function(exports, node) { + + "use strict"; + + // ## Global scope + + var J = node.JSUS; + + // Exposing constructor. + exports.GameSession = GameSession; + exports.GameSession.SessionManager = SessionManager; + + GameSession.prototype = new SessionManager(); + GameSession.prototype.constructor = GameSession; + + /** + * ## GameSession constructor + * + * Creates a new instance of GameSession + * + * @param {NodeGameClient} node A reference to the node object. + */ + function GameSession(node) { + SessionManager.call(this); + + /** + * ### GameSession.node + * + * The reference to the node object. + */ + this.node = node; + + // Register default variables in the session. + this.register('player', { + set: function(p) { + node.createPlayer(p); + }, + get: function() { + return node.player; + } + }); + + this.register('game.memory', { + set: function(value) { + node.game.memory.clear(true); + node.game.memory.importDB(value); + }, + get: function() { + return (node.game.memory) ? node.game.memory.fetch() : null; + } + }); + + this.register('events.history', { + set: function(value) { + node.events.history.history.clear(true); + node.events.history.history.importDB(value); + }, + get: function() { + return node.events.history ? + node.events.history.history.fetch() : null; + } + }); + + this.register('stage', { + set: function() { + // GameSession.restoreStage + }, + get: function() { + return node.player.stage; + } + }); + + this.register('node.env'); + } + + +// GameSession.prototype.restoreStage = function(stage) { +// +// try { +// // GOTO STATE +// node.game.execStage(node.plot.getStep(stage)); +// +// var discard = ['LOG', +// 'STATECHANGE', +// 'WINDOW_LOADED', +// 'BEFORE_LOADING', +// 'LOADED', +// 'in.say.STATE', +// 'UPDATED_PLIST', +// 'NODEGAME_READY', +// 'out.say.STATE', +// 'out.set.STATE', +// 'in.say.PLIST', +// 'STAGEDONE', // maybe not here +// 'out.say.HI' +// ]; +// +// // RE-EMIT EVENTS +// node.events.history.remit(node.game.getStateLevel(), discard); +// node.info('game stage restored'); +// return true; +// } +// catch(e) { +// node.err('could not restore game stage. ' + +// 'An error has occurred: ' + e); +// return false; +// } +// +// }; + + /** + * ## SessionManager constructor + * + * Creates a new session manager. + */ + function SessionManager() { + + /** + * ### SessionManager.session + * + * Container of all variables registered in the session. + */ + this.session = {}; + } + + // ## SessionManager methods + + /** + * ### SessionManager.getVariable (static) + * + * Default session getter. + * + * @param {string} p The path to a variable included in _node_ + * @return {mixed} The requested variable + */ + SessionManager.getVariable = function(p) { + return J.getNestedValue(p, node); + }; + + /** + * ### SessionManager.setVariable (static) + * + * Default session setter. + * + * @param {string} p The path to the variable to set in _node_ + * @param {mixed} value The value to set + */ + SessionManager.setVariable = function(p, value) { + J.setNestedValue(p, value, node); + }; + + /** + * ### SessionManager.register + * + * Register a new variable to the session + * + * Overwrites previously registered variables with the same name. + * + * Usage example: + * + * ```javascript + * node.session.register('player', { + * set: function(p) { + * node.createPlayer(p); + * }, + * get: function() { + * return node.player; + * } + * }); + * ``` + * + * @param {string} path A string containing a path to a variable + * @param {object} conf Optional. Configuration object containing setters + * and getters + */ + SessionManager.prototype.register = function(path, conf) { + if ('string' !== typeof path) { + throw new TypeError('SessionManager.register: path must be ' + + 'string.'); + } + if (conf && 'object' !== typeof conf) { + throw new TypeError('SessionManager.register: conf must be ' + + 'object or undefined.'); + } + + this.session[path] = { + + get: (conf && conf.get) ? + conf.get : function() { + return J.getNestedValue(path, node); + }, + + set: (conf && conf.set) ? + conf.set : function(value) { + J.setNestedValue(path, value, node); + } + }; + + return this.session[path]; + }; + + /** + * ### SessionManager.unregister + * + * Unegister a variable from session + * + * @param {string} path A string containing a path to a variable previously + * registered. + * + * @see SessionManager.register + */ + SessionManager.prototype.unregister = function(path) { + if ('string' !== typeof path) { + throw new TypeError('SessionManager.unregister: path must be ' + + 'string.'); + } + if (!this.session[path]) { + node.warn('SessionManager.unregister: path is not registered ' + + 'in the session: ' + path + '.'); + return false; + } + + delete this.session[path]; + return true; + }; + + /** + * ### SessionManager.clear + * + * Unegister all registered session variables + * + * @see SessionManager.unregister + */ + SessionManager.prototype.clear = function() { + this.session = {}; + }; + + /** + * ### SessionManager.get + * + * Returns the value/s of one/all registered session variable/s + * + * @param {string|undefined} path A previously registred variable or + * undefined to return all values + * + * @see SessionManager.register + */ + SessionManager.prototype.get = function(path) { + var session = {}; + // Returns one variable. + if ('string' === typeof path) { + return this.session[path] ? this.session[path].get() : undefined; + } + // Returns all registered variables. + else if ('undefined' === typeof path) { + for (path in this.session) { + if (this.session.hasOwnProperty(path)) { + session[path] = this.session[path].get(); + } + } + return session; + } + else { + throw new TypeError('SessionManager.get: path must be string or ' + + 'undefined.'); + } + }; + + /** + * ### SessionManager.isRegistered + * + * Returns TRUE, if a variable is registred + * + * @param {string} path A previously registred variable + * + * @return {boolean} TRUE, if the variable is registered + * + * @see SessionManager.register + * @see SessionManager.unregister + */ + SessionManager.prototype.isRegistered = function(path) { + if ('string' !== typeof path) { + throw new TypeError('SessionManager.isRegistered: path must be ' + + 'string.'); + } + return this.session.hasOwnProperty(path); + }; + + /** + * ### SessionManager.serialize + * + * Returns an object containing that can be to restore the session + * + * The serialized session is an object containing _getter_, _setter_, and + * current value of each of the registered session variables. + * + * @return {object} session The serialized session + * + * @see SessionManager.restore + */ + SessionManager.prototype.serialize = function() { + var session = {}; + for (var path in this.session) { + if (this.session.hasOwnProperty(path)) { + session[path] = { + value: this.session[path].get(), + get: this.session[path].get, + set: this.session[path].set + }; + } + } + return session; + }; + + /** + * ### SessionManager.restore + * + * Restore a previously serialized session object + * + * @param {object} session A serialized session object + * @param {boolean} register Optional. If TRUE, every path is also + * registered before being restored. + */ + SessionManager.prototype.restore = function(session, register) { + var i; + if ('object' !== typeof session) { + throw new TypeError('SessionManager.restore: session must be ' + + 'object.'); + } + register = 'undefined' !== typeof register ? register : true; + for (i in session) { + if (session.hasOwnProperty(i)) { + if (register) this.register(i, session[i]); + session[i].set(session[i].value); + } + } + }; + +// SessionManager.prototype.store = function() { +// //node.store(node.socket.id, this.get()); +// }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Timer + * Copyright(c) 2021 Stefano Balietti + * MIT Licensed + * + * Timing-related utility functions + */ +(function(exports, parent) { + + "use strict"; + + // ## Global scope + var J = parent.JSUS; + + // Exposing Timer constructor + exports.Timer = Timer; + + /** + * ## Timer constructor + * + * Creates a new instance of Timer + * + * @param {NodeGameClient} node. A valid NodeGameClient object + * @param {object} settings Optional. A configuration object + */ + function Timer(node, settings) { + var that; + this.node = node; + + this.settings = settings || {}; + + /** + * ### Timer.timers + * + * Collection of currently active timers created by `Timer.createTimer` + * @see Timer.createTimer + */ + this.timers = {}; + + /** + * ### Timer._stepTimers + * + * Collection of temporary timers created in current step + * + * All references are cleared after each step. + * + * Notice: might not be updated, if user manually destroys a timer. + * + * @private + */ + this._stepTimers = []; + + /** + * ### Timer._stageTimers + * + * Collection of temporary timers created in current stage + * + * All references are cleared after each stage. + * + * Notice: might not be updated, if user manually destroys a timer. + * + * @private + */ + this._stageTimers = []; + + /** + * ### Timer.timestamps + * + * Named timestamp collection + * + * Maps names to numbers (milliseconds since epoch) + * + * @see Timer.setTimestamp + * @see Timer.getTimestamp + * @see Timer.getTimeSince + */ + this.timestamps = {}; + + /** + * ### Timer.pausedTimestamps + * + * Collection of timestamps existing while game is paused + * + * Will be cleared on resume + * + * @see Timer.setTimestamp + * @see Timer.getTimestamp + * @see Timer.getTimeSince + */ + this._pausedTimestamps = {}; + + /** + * ### Timer.cumulPausedTimestamps + * + * List of timestamps that had a paused time in between + * + * Persists after resume + * + * @see Timer.setTimestamp + * @see Timer.getTimestamp + * @see Timer.getTimeSince + */ + this._cumulPausedTimestamps = {}; + + /** + * ### Timer._effectiveDiffs + * + * List of time differences between timestamps minus paused time + * + * Persists after resume + * + * @see Timer.setTimestamp + * @see Timer.getTimestamp + * @see Timer.getTimeSince + */ + this._effectiveDiffs = {}; + + that = this; + this.node.on('PAUSED', function() { + var i, ts; + ts = that.getTimestamp('paused'); + for (i in that.timestamps) { + if (that.timestamps.hasOwnProperty(i)) { + that._pausedTimestamps[i] = ts; + } + } + }); + this.node.on('RESUMED', function() { + var i, time, pt, cpt, pausedTime; + pt = that._pausedTimestamps; + cpt = that._cumulPausedTimestamps; + time = J.now(); + for (i in pt) { + if (pt[i] && pt.hasOwnProperty(i)) { + pausedTime = time - pt[i]; + if (!cpt[i]) cpt[i] = pausedTime; + else cpt[i] += pausedTime; + } + } + that._pausedTimestamps = {}; + }); + + /** + * ### Timer.random | Timer.wait + * + * Setups an object exposing multiple random handlers. + * + * Respects pausing / resuming. If the game has not started yet, it waits + * until the `PLAYING` event is fired to start the random handler. + * + * Additional parameters are passed to each handler accordingly. + * + * @param {number} maxWait Optional. The maximum time (in milliseconds) + * to wait before emitting the event. Default: 5000 + * @param {number} minWait Optional. The minimum time (in milliseconds) + * to wait before executing the callback. Default: 1000 + * + * @return {object} Object containing different random handlers + * + * ### Timer.random.done + * + * Randomly calls `node.done` + * + * ### Timer.random.emit + * + * Randomly emits an event + * + * @param {string} event The name of the event + * + * ### Timer.random.exec + * + * Randomly executs a function + * + * @param {function} func The callback function to execute + * @param {object|function} ctx Optional. The context of execution of + * of the callback function. Default node.game + * + * @see randomFire + */ + (function(that) { + var _minWait, _maxWait, _prob; + var args, i, len; + + // Init certain probability. + _prob = 1; + + + /** + * ### randomFire + * + * Common handler for firing/emitting + * + * @param {string} method The name of the method invoking randomFire + * @param {string|function} hook The function to call or the + * event to emit + * @param {boolean} emit TRUE, if it is an event to emit + * @param {object|function} ctx Optional. The context of + * execution for the function + */ + function randomFire(method, hook, emit, ctx, args) { + var that; + var waitTime; + var callback; + var timerObj; + var tentativeName; + + that = this; + + if ('undefined' === typeof _maxWait) { + _maxWait = 5000; + } + else if ('number' !== typeof _maxWait) { + resetWaits(); + throw new TypeError('Timer.' + method + ': maxWait must ' + + 'be number or undefined. Found: ' + + _maxWait); + } + if ('undefined' === typeof _minWait) { + _minWait = _maxWait < 1000 ? 0 : 1000; + } + else if ('number' !== typeof _minWait) { + resetWaits(); + throw new TypeError('Timer.' + method + ': minWait must ' + + 'be number or undefined. Found: ' + + _minWait); + } + + waitTime = J.randomInt(_minWait, _maxWait); + + // Timeup callback: Emit. + if (emit) { + callback = function() { + that.destroyTimer(timerObj); + if (args) { + that.node.emit.apply(that.node.events, + [hook].concat(args)); + } + else { + that.node.emit(hook); + } + }; + } + // Timeup callback: Exec. + else { + callback = function() { + that.destroyTimer(timerObj); + hook.apply(ctx, args); + }; + } + + tentativeName = method + '_' + hook + '_' + + J.randomInt(0, 1000000); + + // Create and run timer: + timerObj = this.createTimer({ + milliseconds: waitTime, + timeup: callback, + name: J.uniqueKey(this.timers, tentativeName) + }); + + // TODO: check if this condition is ok. + if (this.node.game && this.node.game.isReady()) { + timerObj.start(); + } + else { + // TODO: this is not enough. Does not cover all use cases. + this.node.once('PLAYING', function() { + timerObj.start(); + }); + } + + resetWaits(); + } + + function resetWaits() { + // Reset min and max wait to default. + // Need to do it here, because user can access the random + // and wait functions without executing them. + _maxWait = 5000; + _minWait = 1000; + } + + function done(param) { + + // Probalistic abort. + if (!evaluateProb()) return; + + randomFire.call(that, 'done', node.done, false, node, [param]); + } + + function emit(event) { + + if ('string' !== typeof event) { + throw new TypeError('Timer.emit: event must be ' + + 'string. Found: ' + event); + } + + // Probalistic abort. + if (!evaluateProb()) return; + + len = arguments.length; + if (len === 2) { + args = [arguments[1]]; + } + else if (len === 3) { + args = [arguments[2], arguments[1]]; + } + else if (len > 3) { + i = -1, len = (len-1); + args = new Array(len); + for ( ; ++i < len ; ) { + args[i] = arguments[i+1]; + } + } + randomFire.call(that, 'emit', event, true, null, args); + } + + function exec(func, ctx) { + if ('function' !== typeof func) { + throw new TypeError('Timer.exec: func must ' + + 'be function. Found: ' + func); + } + if ('undefined' === typeof ctx) { + ctx = node.game; + } + else if ('object' !== typeof ctx && 'function' !== typeof ctx) { + throw new TypeError('Timer.exec: ctx must be ' + + 'object, function or undefined. ' + + 'Found: ' + ctx); + } + + // Probalistic abort. + if (!evaluateProb()) return; + + len = arguments.length; + if (len === 3) { + args = [arguments[2]]; + } + else if (len === 4) { + args = [arguments[3], arguments[2]]; + } + else if (len > 4) { + i = -1, len = (len-2); + args = new Array(len); + for ( ; ++i < len ; ) { + args[i] = arguments[i+2]; + } + } + randomFire.call(that, 'exec', func, false, ctx, args); + } + + function timeup(param) { + // Probalistic abort. + if (!evaluateProb()) return; + + randomFire.call(that, 'timeup', + function() { node.game.timer.doTimeUp(); }, + false, node.game, [param]); + } + + function evaluateProb() { + var p; + // Get current value and resets it to 1. + p = _prob; + _prob = 1; + if ('number' === typeof p) return Math.random() <= p; + // It is either number of function. + return !!p.call(node.game); + } + + function prob(prob) { + var tmp; + if ('undefined' === typeof prob) { + _prob = 0.5; + } + else if ('function' === typeof prob) { + _prob = prob; + } + else { + tmp = J.isNumber(prob, 0, 1, true, true); + if (tmp === false) { + throw new Error('Timer.prob: ' + + 'prob must be a number between 0 and ' + + '1, undefined or function. Found: ' + + prob); + } + _prob = tmp; + } + + return { + done: done, + emit: emit, + exec: exec, + timeup: timeup + }; + } + + // Random and Wait functions. + + function random(maxWait, minWait) { + _maxWait = maxWait; + _minWait = minWait; + + return { + done: done, + emit: emit, + exec: exec, + timeup: timeup, + prob: prob + }; + } + + function wait(wait) { + return random(wait, wait); + } + + // Make the handlers properties of the random and wait functions. + random.done = done; + random.emit = emit; + random.exec = exec; + random.prob = prob; + random.timeup = timeup; + + wait.done = done; + wait.emit = emit; + wait.exec = exec; + wait.prob = prob; + wait.timeup = timeup; + + // Assign random and wait functions to Timer. + that.random = random; + that.wait = wait; + })(this); + + } + + // ## Timer methods + + /** + * ### Timer.setTimeout + * + * Wrapper for createTimer with same syntax as JS setTimeout + * + * @param {function|string} timeup A callback function or an event to emit + * when the timeout is fired + * @param {number} milliseconds The delay for the timeout in milliseconds. + * Default: 1 (because zero would fire immediately and we want to keep + * the same behavior of JS setTimeout) + * @param {string} validity The validity of the timeout. Default: validity + * changes depending on where the timeout is created (game, stage, step). + * + * @return {GameTimer} The game timer + * + * @see GameTimer + */ + Timer.prototype.setTimeout = function(timeup, milliseconds, validity) { + return this.createTimer({ + milliseconds: milliseconds || 1, + timeup: timeup, + validity: validity + }).start(); + }; + + /** + * ### Timer.createTimer | Timer.create + * + * Returns a new GameTimer + * + * The GameTimer instance is automatically paused and resumed on + * the respective events. + * + * Timer creation is flexible, and input parameter can be a full + * configuration object, the number of milliseconds or nothing. In the + * latter case, the new timer will need to be configured manually. If + * only the number of milliseconds is passed the timer will fire a 'TIMEUP' + * event once the time expires. + * + * @param {mixed} options The configuration object passed to the GameTimer + * constructor. Alternatively, it is possible to pass directly the number + * of milliseconds and the remaining settings will be added, or to leave + * it undefined. + * + * @return {GameTimer} timer The requested timer + * + * @see GameTimer + */ + Timer.prototype.create = Timer.prototype.createTimer = function(options) { + var gameTimer, pausedCb, resumedCb; + var ee, val; + + if (options && + ('object' !== typeof options && 'number' !== typeof options)) { + + throw new TypeError('Timer.createTimer: options must be ' + + 'undefined, object or number. Found: ' + + options); + } + + if ('number' === typeof options) options = { milliseconds: options }; + options = options || {}; + + options.name = options.name || + J.uniqueKey(this.timers, 'timer_' + J.randomInt(0, 10000000)); + + if (this.timers[options.name]) { + throw new Error('Timer.createTimer: timer name already in use: ' + + options.name); + } + + // Retrieve the event emitter where the listeners are registered + // based on options `validity` or the currently active event emitter. + // If validity is 'step' or 'stage', a reference will be added to + // the corresponding temporary array of timers (below). + val = options.validity; + if (!val) { + ee = this.node.getCurrentEventEmitter(); + } + else { + ee = this.node.events.ee[val]; + if (!ee) { + throw new Error('Timer.createTimer: validity must be "ng", ' + + '"game", "stage", "step". Found: ' + val); + } + } + options.eventEmitterName = ee.name; + + // If game is paused add options startPaused, unless user + // specified a value in the options object. + if (this.node.game && this.node.game.paused) { + if ('undefined' === typeof options.startPaused) { + options.startPaused = true; + } + } + + // Create the GameTimer: + gameTimer = new GameTimer(this.node, options); + + // Attach pause / resume listeners: + pausedCb = function() { + if (!gameTimer.isPaused()) { + gameTimer.pause(); + } + }; + resumedCb = function() { + // startPaused=true also counts as a "paused" state: + if (gameTimer.isPaused() || gameTimer.startPaused) { + gameTimer.resume(); + } + }; + + ee.on('PAUSED', pausedCb); + ee.on('RESUMED', resumedCb); + + // Attach listener handlers to GameTimer object so they can be + // unregistered later: + gameTimer.timerPausedCallback = pausedCb; + gameTimer.timerResumedCallback = resumedCb; + + // Add a reference into this.timers. + this.timers[gameTimer.name] = gameTimer; + + // Add reference to stage and step temporary timers. + if (ee.name === 'step') this._stepTimers.push(gameTimer); + else if (ee.name === 'stage') this._stageTimers.push(gameTimer); + + return gameTimer; + }; + + /** + * ### Timer.destroyTimer + * + * Stops and removes a GameTimer + * + * The event handlers listening on PAUSED/RESUMED that are attached to + * the given GameTimer object are removed. + * + * @param {object|string} gameTimer The gameTimer object or the name of + * the gameTimer created with Timer.createTimer + */ + Timer.prototype.destroyTimer = function(gameTimer) { + var eeName; + if ('string' === typeof gameTimer) { + if (!this.timers[gameTimer]) { + throw new Error('node.timer.destroyTimer: gameTimer not ' + + 'found: ' + gameTimer); + } + gameTimer = this.timers[gameTimer]; + } + if ('object' !== typeof gameTimer) { + throw new Error('node.timer.destroyTimer: gameTimer must be ' + + 'string or object. Found: ' + gameTimer); + } + + // Stop timer. + if (!gameTimer.isStopped()) { + gameTimer.stop(); + } + + eeName = gameTimer.eventEmitterName; + // Detach listeners. + if (eeName) { + // We know where the timer was registered. + this.node.events.ee[eeName].remove('PAUSED', + gameTimer.timerPausedCallback); + this.node.events.ee[eeName].remove('RESUMED', + gameTimer.timerResumedCallback); + } + else { + // We try to unregister from all. + this.node.off('PAUSED', gameTimer.timerPausedCallback); + this.node.off('RESUMED', gameTimer.timerResumedCallback); + } + + // Remove listener syncing with stager (if any). + gameTimer.syncWithStager(false); + + + // Set status to DESTROYED and make object unusable + // (in case external references to the object still exists). + gameTimer.status = GameTimer.DESTROYED; + + // Delete reference in this.timers. + delete this.timers[gameTimer.name]; + }; + + /** + * ### Timer.destroyStepTimers + * + * Stops and removes all timers registered in current step + */ + Timer.prototype.destroyStepTimers = function() { + destroyTempTimers(this, '_stepTimers'); + }; + + /** + * ### Timer.destroyStageTimers + * + * Stops and removes all timers registered in current stage + */ + Timer.prototype.destroyStageTimers = function() { + destroyTempTimers(this, '_stageTimers'); + }; + + /** + * ### Timer.destroyAllTimers + * + * Stops and removes all registered GameTimers + * + * By default, node.game.timer is not removed. + * + * @param {boolean} all Removes really all timers, including + * node.game.timer + */ + Timer.prototype.destroyAllTimers = function(all) { + var i; + for (i in this.timers) { + if (this.timers.hasOwnProperty(i)) { + // Skip node.game.timer, unless so specified. + if (!all && i === this.node.game.timer.name) continue; + this.destroyTimer(this.timers[i]); + } + } + // Clear temporary timers. + this._stepTimers = []; + this._stageTimers = []; + }; + + /** + * ### Timer.getTimer + * + * Returns a reference to a previosly registered game timer. + * + * @param {string} name The name of the timer + * + * @return {GameTimer|null} The game timer with the given name, or + * null if none is found + */ + Timer.prototype.getTimer = function(name) { + if ('string' !== typeof name) { + throw new TypeError('Timer.getTimer: name must be string. Found: ' + + name); + } + return this.timers[name] || null; + }; + + /** + * ### Timer.setTimestamp + * + * Adds or changes a named timestamp + * + * @param {string} name The name of the timestamp + * @param {number|undefined} time Optional. The time in ms as returned by + * Date.getTime(). Default: Current time. + * + * @return {number} time The value of the timestamp set + * + * @see Timer.getTimestamp + */ + Timer.prototype.setTimestamp = function(name, time) { + var i; + // Default time: Current time + if ('undefined' === typeof time) time = J.now(); + + // Check inputs: + if ('string' !== typeof name) { + throw new Error('Timer.setTimestamp: name must be a string. ' + + 'Found: ' + name); + } + if ('number' !== typeof time) { + throw new Error('Timer.setTimestamp: time must be a number or ' + + 'undefined. Found: ' + time); + } + + // We had at least one pause. + if (this.node.game.pauseCounter) { + // Remove records of paused timestamps. + if (this._pausedTimestamps[name]) { + this._pausedTimestamps[name] = null; + } + if (this._cumulPausedTimestamps[name]) { + this._cumulPausedTimestamps[name] = null; + } + // Mark timestamp as paused since the beginning, if game is paused. + if (this.node.game.isPaused()) this._pausedTimestamps[name] = time; + + // Diffs are updated immediately. + this._effectiveDiffs[name] = {}; + for (i in this.timestamps) { + if (this.timestamps.hasOwnProperty(i)) { + this._effectiveDiffs[name][i] = this.getTimeSince(i, true); + } + } + } + this.timestamps[name] = time; + return time; + }; + + /** + * ### Timer.getTimestamp + * + * Retrieves a named timestamp + * + * @param {string} name The name of the timestamp + * + * @return {number|null} The time associated with the timestamp, + * NULL if it doesn't exist + */ + Timer.prototype.getTimestamp = function(name) { + // Check input: + if ('string' !== typeof name) { + throw new Error('Timer.getTimestamp: name must be a string. ' + + 'Found: ' + name); + } + if (this.timestamps.hasOwnProperty(name)) return this.timestamps[name]; + else return null; + }; + + /** + * ### Timer.getAllTimestamps + * + * Returns the map with all timestamps + * + * Do not change the returned object. + * + * @return {object} The timestamp map + */ + Timer.prototype.getAllTimestamps = function() { + return this.timestamps; + }; + + /** + * ### Timer.getTimeSince + * + * Gets the time in ms since a timestamp + * + * @param {string} name The name of the timestamp + * @param {boolean} effective Optional. If set, effective time + * is returned, i.e. time minus paused time. Default: false. + * + * @return {number|null} The time since the timestamp in ms, + * NULL if it doesn't exist + * + * @see Timer.getTimeDiff + */ + Timer.prototype.getTimeSince = function(name, effective) { + var currentTime; + + // Get current time: + currentTime = J.now(); + + // Check input: + if ('string' !== typeof name) { + throw new TypeError('Timer.getTimeSince: name must be string. ' + + 'Found: ' + name); + } + + if (this.timestamps.hasOwnProperty(name)) { + if (effective) { + if (this._pausedTimestamps[name]) { + currentTime -= (currentTime - this._pausedTimestamps[name]); + } + if (this._cumulPausedTimestamps[name]) { + currentTime -= this._cumulPausedTimestamps[name]; + } + } + return currentTime - this.timestamps[name]; + } + else { + return null; + } + }; + + /** + * ### Timer.getTimeDiff + * + * Returns the time difference between two registered timestamps + * + * @param {string} nameFrom The name of the first timestamp + * @param {string} nameTo The name of the second timestamp + * @param {boolean} effective Optional. If set, effective time + * is returned, i.e. time diff minus paused time. Default: false. + * + * @return {number} The time difference between the timestamps + */ + Timer.prototype.getTimeDiff = function(nameFrom, nameTo, effective) { + var timeFrom, timeTo, ed; + + // Check input: + if ('string' !== typeof nameFrom) { + throw new TypeError('Timer.getTimeDiff: nameFrom must be string.' + + 'Found: ' + nameFrom); + } + if ('string' !== typeof nameTo) { + throw new TypeError('Timer.getTimeDiff: nameTo must be string. ' + + 'Found: ' + nameTo); + } + + timeFrom = this.timestamps[nameFrom]; + + if ('undefined' === typeof timeFrom || timeFrom === null) { + throw new Error('Timer.getTimeDiff: nameFrom does not resolve to ' + + 'a valid timestamp: ' + nameFrom); + } + + timeTo = this.timestamps[nameTo]; + + if ('undefined' === typeof timeTo || timeTo === null) { + throw new Error('Timer.getTimeDiff: nameTo does not resolve to ' + + 'a valid timestamp: ' + nameTo); + } + + if (effective) { + ed = this._effectiveDiffs; + if (ed[nameFrom] && ed[nameFrom][nameTo]) { + return ed[nameFrom][nameTo]; + } + else if (ed[nameTo] && ed[nameTo][nameFrom]) { + return ed[nameTo][nameFrom]; + } + } + + return timeTo - timeFrom; + }; + + /** + * ## Timer.parseInput + * + * Resolves an unknown value to a valid time quantity (number >=0) + * + * Valid types and operation: + * - number (as is), + * - string (casted), + * - object (property _name_ is parsed), + * - function (will be invoked with `node.game` context) + * + * Parsed value must be a number >= 0 + * + * @param {string} name The name of the property if value is object + * @param {number} value The value to parse + * @param {string} methodName Optional. The name of the method invoking + * the function for the error messsage. Default: Timer.parseInput + * + * @param {number} The parsed value + */ + Timer.prototype.parseInput = function(name, value, methodName) { + var typeofValue, num; + if ('string' !== typeof name) { + throw new TypeError((methodName || 'Timer.parseInput') + + ': name must be string. Found: ' + name); + } + typeofValue = typeof value; + switch (typeofValue) { + + case 'number': + num = value; + break; + case 'object': + if (null !== value) { + if ('function' === typeof value[name]) { + num = value[name].call(this.node.game); + } + } + break; + case 'function': + num = value.call(this.node.game); + break; + case 'string': + num = Number(value); + break; + } + + if ('number' !== typeof num || num < 0) { + throw new Error((methodName || 'Timer.parseInput') + + ': ' + name + ' must be number >= 0. Found: ' + + num); + } + + return num; + }; + + // ## Helper Methods. + + /** + * ### destroyTempTimers + * + * Common handler for randomEmit, randomExec, randomDone + * + * @param {Timer} that A live Timer instance + * @param {string} timers The tname of the collection of temp timers + */ + function destroyTempTimers(that, timers) { + var i, len, t; + len = that[timers].length; + for (i = 0; i < len; i++) { + t = that[timers][i]; + if (t.status !== GameTimer.DESTROYED) that.destroyTimer(t); + } + that[timers] = []; + } + + /** + * # GameTimer + * + * Copyright(c) 2016 Stefano Balietti + * MIT Licensed + * + * Creates a controllable timer object for nodeGame. + */ + exports.GameTimer = GameTimer; + + /** + * ### GameTimer status levels + * Numerical levels representing the state of the GameTimer + * + * @see GameTimer.status + */ + GameTimer.STOPPED = -5; + GameTimer.PAUSED = -3; + GameTimer.UNINITIALIZED = -1; + GameTimer.INITIALIZED = 0; + GameTimer.LOADING = 3; + GameTimer.RUNNING = 5; + GameTimer.DESTROYED = 10; + + /** + * ## GameTimer constructor + * + * Creates an instance of GameTimer + * + * @param {object} options. Optional. A configuration object + */ + function GameTimer(node, options) { + options = options || {}; + + // ## Public properties + + /** + * ### node + * + * Internal reference to node + */ + this.node = node; + + /** + * ### name + * + * Internal name of the timer + */ + this.name = options.name || 'timer_' + J.randomInt(0, 1000000); + + /** + * ### GameTimer.status + * + * Numerical index keeping the current the state of the GameTimer obj + */ + this.status = GameTimer.UNINITIALIZED; + + /** + * ### GameTimer.options + * + * The current settings for the GameTimer + */ + this.options = options; + + /** + * ### GameTimer.timerId + * + * The ID of the javascript interval + */ + this.timerId = null; + + /** + * ### GameTimer.timeLeft + * + * Total running time of timer + */ + this.milliseconds = null; + + /** + * ### GameTimer.timeLeft + * + * Milliseconds left before time is up + */ + this.timeLeft = null; + + /** + * ### GameTimer.timeLeft + * + * Milliseconds left when the last stop was called + */ + this.timeLeftAtStop = null; + + /** + * ### GameTimer.timePassed + * + * Milliseconds already passed from the start of the timer + */ + this.timePassed = 0; + + /** + * ### GameTimer.timePassed + * + * Milliseconds already passed when the last stop was called + */ + this.timePassedAtStop = null; + + /** + * ### GameTimer.update + * + * The frequency of update for the timer (in milliseconds) + */ + this.update = undefined; + + /** + * ### GameTimer.updateRemaining + * + * Milliseconds remaining for current update + */ + this.updateRemaining = 0; + + /** + * ### GameTimer.updateStart + * + * Timestamp of the start of the last update + */ + this.updateStart = 0; + + /** + * ### GameTimer.startPaused + * + * Whether to enter the pause state when starting + */ + this.startPaused = null; + + /** + * ### GameTimer.timeup + * + * Event string or function to fire when the time is up + * + * @see GameTimer.fire + */ + this.timeup = 'TIMEUP'; + + /** + * ### GameTimer.hooks + * + * Array of hook functions to fire at every update + * + * The array works as a LIFO queue + * + * @see GameTimer.fire + */ + this.hooks = []; + + /** + * ### GameTimer.hookNames + * + * Object containing all names used for the hooks + * + * @see GameTimer.hooks + */ + this.hookNames = {}; + + /** + * ### GameTimer.eventEmitterName + * + * The name of the event emitter where the timer was registered + * + * @see EventEmitter + */ + this.eventEmitterName = null; + + /** + * ## GameTimer.stagerSync + * + * TRUE if the timer is synced with stager + * + * It will use GameTimer.stagerProperty to sync + * + * @see GameTimer.stagerProperty + * @see GameTimer.syncWithStager + */ + this.stagerSync = false; + + /** + * ## GameTimer.stagerProperty + * + * The name of the property used to sync with the stager + * + * @see GameTimer.stagerSync + */ + this.stagerProperty = 'timer'; + + // Init! + this.init(this.options); + } + + // ## GameTimer methods + + /** + * ### GameTimer.init + * + * Inits the GameTimer + * + * Takes the configuration as an input parameter or + * recycles the settings in `this.options`. + * + * The configuration object is of the type + * + * ```js + * var options = { + * // The length of the interval. + * milliseconds: 4000, + * // How often to update the time counter. Default: milliseconds + * update: 1000, + * // An event or function to fire when the timer expires. + * timeup: 'MY_EVENT', + * hooks: [ + * // Array of functions or events to fire at every update. + * myFunc, + * 'MY_EVENT_UPDATE', + * { hook: myFunc2, + * ctx: that, }, + * ], + * // Sync with the 'timer' property of the stager + * stagerSync: true, + * // Name of the property to listen to (Default 'timer') + * stagerProperty: 'timer' + * } + * // Units are in milliseconds. + * ``` + * + * Note: if `milliseconds` is a negative number the timer fires + * immediately. + * + * @param {object} options Optional. Configuration object + * + * @return {GameTimer} The game timer instance for chaining + * + * @see GameTimer.addHook + */ + GameTimer.prototype.init = function(options) { + var i, len, node; + checkDestroyed(this, 'init'); + this.status = GameTimer.UNINITIALIZED; + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + } + if (options) { + if ('object' !== typeof options) { + throw new TypeError('GameTimer.init: options must be object ' + + 'or undefined. Found: ' + options); + } + node = this.node; + if ('undefined' !== typeof options.milliseconds) { + this.milliseconds = node.timer.parseInput('milliseconds', + options.milliseconds); + } + if ('undefined' !== typeof options.update) { + this.update = node.timer.parseInput('update', options.update); + } + else { + // We keep the current update if not modified. + this.update = this.update || this.milliseconds; + } + + // Event to be fired when timer expires. + if (options.timeup) { + if ('function' === typeof options.timeup || + 'string' === typeof options.timeup) { + + this.timeup = options.timeup; + } + else { + throw new TypeError('GameTimer.init: options.timeup must ' + + 'be function or undefined. Found: ' + + options.timeup); + } + } + else { + this.timeup = this.timeup || 'TIMEUP'; + } + + if (options.hooks) { + if (J.isArray(options.hooks)) { + len = options.hooks.length; + for (i = 0; i < len; i++) { + this.addHook(options.hooks[i]); + } + } + else { + this.addHook(options.hooks); + } + } + + // Set startPaused option. if specified. Default: FALSE + this.startPaused = 'undefined' !== options.startPaused ? + options.startPaused : false; + + if ('string' === typeof options.eventEmitterName) { + this.eventEmitterName = options.eventEmitterName; + } + // Stager sync options. + if ('undefined' !== typeof options.stagerSync) { + this.syncWithStager(options.stagerSync); + } + if ('undefined' !== typeof options.stagerProperty) { + this.setStagerProperty(options.stagerProperty); + } + + } + + // TODO: check if this TODO is correct. + // TODO: update and milliseconds must be multiple now. + + this.timeLeft = this.milliseconds; + this.timePassed = 0; + this.updateStart = 0; + this.updateRemaining = 0; + this._timeup = false; + + // Only set status to INITIALIZED if all of the state is valid and + // ready to be used by this.start etc. + if (checkInitialized(this) === null) { + this.status = GameTimer.INITIALIZED; + } + + return this; + }; + + + /** + * ### GameTimer.fire + * + * Fires a registered hook + * + * If hook is a string it is emitted as an event, + * otherwise it is called as a function. + * + * @param {mixed} h The hook to fire (object, function, or string) + * + * @return {GameTimer} The game timer instance for chaining + */ + GameTimer.prototype.fire = function(h) { + var hook, ctx; + checkDestroyed(this, 'fire'); + if ('object' === typeof h) { + hook = h.hook; + ctx = h.ctx; + h = hook; + } + + if ('function' === typeof h) { + h.call(ctx || this.node.game, this.timeLeft, this); + } + else if ('string' === typeof h) { + this.node.emit(h, this.timeLeft, this); + } + else { + throw new TypeError('GameTimer.fire: h must be function, string ' + + 'or object. Found: ' + h); + } + + return this; + }; + + /** + * ### GameTimer.start + * + * Starts the timer + * + * Updates the status of the timer and calls `setInterval` + * At every update all the registered hooks are fired, and + * time left is checked. + * + * When the timer expires the timeup event is fired, and the + * timer is stopped + * + * @return {GameTimer} The game timer instance for chaining + * + * @see GameTimer.status + * @see GameTimer.timeup + * @see GameTimer.fire + */ + GameTimer.prototype.start = function() { + var error, that; + checkDestroyed(this, 'start'); + // Check validity of state + error = checkInitialized(this); + if (error !== null) { + throw new Error('GameTimer.start: ' + error); + } + + if (this.isRunning()) { + throw new Error('GameTimer.start: timer is already running'); + } + + this.status = GameTimer.LOADING; + + if (this.startPaused) { + this.pause(); + return this; + } + + // Remember time of start (used by this.pause to compute remaining time) + this.updateStart = J.now(); + + // Fires the event immediately if time is zero. + // Double check necessary in strict mode. + if ('undefined' !== typeof this.options.milliseconds && + this.options.milliseconds <= 0) { + + this.doTimeup(); + return this; + } + + this.updateRemaining = this.update; + + that = this; + // It is not possible to pass extra parameters to updateCallback, + // by adding them after _this.update_. In IE does not work. + this.timerId = setInterval(function() { + updateCallback(that); + }, this.update); + + return this; + }; + + /** + * ### GameTimer.addHook + * + * Add an hook to the hook list after performing conformity checks + * + * The first parameter can be a string, a function, or an object + * containing an hook property. + * + * @param {string|function|object} hook The hook (string or function), + * or an object containing a `hook` property (others: `ctx` and `name`) + * @param {object} ctx The context wherein the hook is called. + * Default: node.game + * @param {string} name The name of the hook. Default: a random name + * starting with 'timerHook' + * + * @return {string} The name of the hook + */ + GameTimer.prototype.addHook = function(hook, ctx, name) { + checkDestroyed(this, 'addHook'); + if ('undefined' === typeof hook) { + throw new TypeError('GameTimer.addHook: hook must be function, ' + + 'string or object. Found: ' + hook); + } + ctx = ctx || this.node.game; + if (hook.hook) { + ctx = hook.ctx || ctx; + if (hook.name) name = hook.name; + hook = hook.hook; + } + if (!name) { + name = J.uniqueKey(this.hookNames, 'timerHook'); + } + else if (this.hookNames[name]) { + throw new Error('GameTimer.addHook: name already existing: ' + + name); + } + this.hookNames[name] = true; + this.hooks.push({hook: hook, ctx: ctx, name: name}); + + return name; + }; + + /** + * ### GameTimer.removeHook + * + * Removes a hook by its name + * + * @param {string} name Name of the hook to be removed + * + * @return {mixed} the hook if it was removed; false otherwise. + */ + GameTimer.prototype.removeHook = function(name) { + var i; + checkDestroyed(this, 'removeHook'); + if (this.hookNames[name]) { + for (i = 0; i < this.hooks.length; i++) { + if (this.hooks[i].name === name) { + delete this.hookNames[name]; + return this.hooks.splice(i,1); + } + } + } + return false; + }; + + /** + * ### GameTimer.pause + * + * Pauses the timer + * + * If the timer was running, clear the interval and sets the + * status property to `GameTimer.PAUSED`. + * + * @return {GameTimer} The game timer instance for chaining + */ + GameTimer.prototype.pause = function() { + var timestamp; + checkDestroyed(this, 'pause'); + if (this.isRunning()) { + clearInterval(this.timerId); + clearTimeout(this.timerId); + this.timerId = null; + + this.status = GameTimer.PAUSED; + + // Save time of pausing. + // If start was never called, or called with startPaused on. + if (this.updateStart === 0) { + this.updateRemaining = this.update; + } + else { + // Save the difference of time left. + timestamp = J.now(); + this.updateRemaining = + this.update - (timestamp - this.updateStart); + } + } + else if (this.status === GameTimer.STOPPED) { + // If the timer was explicitly stopped, we ignore the pause: + return this; + } + else if (!this.isPaused()) { + // pause() was called before start(); remember it: + this.startPaused = true; + } + else { + throw new Error('GameTimer.pause: timer was already paused'); + } + + return this; + }; + + /** + * ### GameTimer.resume + * + * Resumes a paused timer + * + * If the timer was paused, restarts it with the current configuration + * + * @return {GameTimer} The game timer instance for chaining + * + * @see GameTimer.restart + */ + GameTimer.prototype.resume = function() { + var that; + checkDestroyed(this, 'resume'); + + // Don't start if the initialization is incomplete (invalid state): + if (this.status === GameTimer.UNINITIALIZED) { + this.startPaused = false; + return this; + } + + if (!this.isPaused() && !this.startPaused) { + throw new Error('GameTimer.resume: timer was not paused'); + } + + this.status = GameTimer.LOADING; + + this.startPaused = false; + + this.updateStart = J.now(); + + that = this; + // Run rest of this "update" interval: + this.timerId = setTimeout(function() { + if (updateCallback(that)) { + // start() needs the timer to not be running. + that.status = GameTimer.INITIALIZED; + + that.start(); + + // start() sets status to LOADING, so change it back to RUNNING. + that.status = GameTimer.RUNNING; + } + }, this.updateRemaining); + + return this; + }; + + /** + * ### GameTimer.stop + * + * Stops the timer + * + * If the timer was paused or running, clear the interval, sets the + * status property to `GameTimer.STOPPED`, and reset the time passed + * and time left properties + * + * @return {GameTimer} The game timer instance for chaining + */ + GameTimer.prototype.stop = function() { + checkDestroyed(this, 'stop'); + if (this.isStopped()) { + throw new Error('GameTimer.stop: timer was not running'); + } + + this.status = GameTimer.STOPPED; + clearInterval(this.timerId); + clearTimeout(this.timerId); + this.timerId = null; + this.timePassedAtStop = this.timePassed; + this.timePassed = 0; + this.timeLeftAtStop = this.timeLeft; + this.timeLeft = null; + this.startPaused = null; + this.updateRemaining = 0; + this.updateStart = 0; + + return this; + }; + + /** + * ### GameTimer.reset + * + * Resets the timer + * + * Stops the timer, sets the status to UNINITIALIZED, and + * sets the following properties to default: milliseconds, + * update, timeup, hooks, hookNames. + * + * Does **not** change properties: eventEmitterName, and + * stagerSync. + * + * @return {GameTimer} The game timer instance for chaining + */ + GameTimer.prototype.reset = function() { + checkDestroyed(this, 'reset'); + if (!this.isStopped()) this.stop(); + this.options = {}; + this.milliseconds = null; + this.update = undefined; + this.timeup = 'TIMEUP'; + this.hooks = []; + this.hookNames = {}; + + return this; + }; + + /** + * ### GameTimer.restart + * + * Restarts the timer + * + * Uses the input parameter as configuration object, + * or the current settings, if undefined + * + * @param {object} options Optional. A configuration object + * + * @return {GameTimer} The game timer instance for chaining + * + * @see GameTimer.init + */ + GameTimer.prototype.restart = function(options) { + checkDestroyed(this, 'restart'); + if (!this.isStopped()) this.stop(); + this.init(options); + return this.start(); + }; + + /** + * ### GameTimer.isRunning + * + * Returns whether timer is running + * + * Running means either LOADING or RUNNING. + * + * @return {boolean} TRUE if timer is running + */ + GameTimer.prototype.isRunning = function() { + checkDestroyed(this, 'isRunning'); + return (this.status > 0); + }; + + /** + * ### GameTimer.isStopped + * + * Returns whether timer is stopped + * + * Stopped means either UNINITIALIZED, INITIALIZED or STOPPED. + * + * @return {boolean} TRUE if timer is stopped + * + * @see GameTimer.isPaused + */ + GameTimer.prototype.isStopped = function() { + checkDestroyed(this, 'isStopped'); + return (this.status === GameTimer.UNINITIALIZED || + this.status === GameTimer.INITIALIZED || + this.status === GameTimer.STOPPED); + }; + + /** + * ### GameTimer.isPaused + * + * Returns whether timer is paused + * + * @return {boolean} TRUE if timer is paused + */ + GameTimer.prototype.isPaused = function() { + checkDestroyed(this, 'isPaused'); + return this.status === GameTimer.PAUSED; + }; + + /** + * ### GameTimer.isDestroyed + * + * Returns TRUE if the timer is destroyed + * + * @return {boolean} TRUE if timer is destroyed + */ + GameTimer.prototype.isDestroyed = function() { + return this.status === GameTimer.DESTROYED; + }; + + /** + * ### GameTimer.isTimeUp | isTimeup + * + * Return TRUE if the time expired + * + * If timer was stopped before expiring returns FALSE + * + * @return {boolean} TRUE if a timeup occurred from last initialization + */ + GameTimer.prototype.isTimeUp = GameTimer.prototype.isTimeup = function() { + checkDestroyed(this, 'isTimeup'); + return this._timeup; + }; + + /** + * ## GameTimer.syncWithStager + * + * Enables listeners to events and reads options from stager + * + * @param {boolean|undefined} sync TRUE to sync, FALSE to remove sync. + * If undefined no operation is performed, and simply the current + * value is returned. + * @param {string} property Optional. Name of the property of the stager + * from which load timer information. Default: 'timer' + * + * @return {boolean} TRUE if synced, FALSE otherwise + * + * @see GameTimer.setStagerProperty + */ + GameTimer.prototype.syncWithStager = function(sync, property) { + var node, that, ee; + checkDestroyed(this, 'syncWithStager'); + if ('undefined' === typeof sync) return this.stagerSync; + if ('boolean' !== typeof sync) { + throw new TypeError('GameTimer.syncWithStager: sync must be ' + + 'boolean or undefined. Found: ' + sync); + } + if (property) { + if ('string' !== typeof property) { + throw new TypeError('GameTimer.syncWithStager: property ' + + 'must be string or undefined. Found: ' + + property); + } + this.setStagerProperty(property); + } + // Do nothing if no change of status is required. + if (this.syncWithStager() === sync) return sync; + node = this.node; + ee = node.events[this.eventEmitterName]; + if (sync === true) { + that = this; + + // On PLAYING starts. + ee.on('PLAYING', function() { + var options; + options = that.getStepOptions(); + if (options) that.restart(options); + }, this.name + '_PLAYING'); + + // On REALLY_DONE stops. + ee.on('REALLY_DONE', function() { + if (!that.isStopped()) that.stop(); + }, this.name + '_REALLY_DONE'); + } + else { + ee.off('PLAYING', this.name + '_PLAYING'); + ee.off('REALLY_DONE', this.name + '_REALLY_DONE'); + } + + // Store value. + this.stagerSync = sync; + return sync; + }; + + /** + * ### GameTimer.doTimeUp | doTimeup + * + * Stops the timer and calls the timeup + * + * It will call timeup even if the game is paused/stopped, + * but not if timeup was already called. + * + * @return {GameTimer} The game timer instance for chaning + * + * @see GameTimer.isTimeup + * @see GameTimer.stop + * @see GameTimer.fire + */ + GameTimer.prototype.doTimeUp = GameTimer.prototype.doTimeup = function() { + checkDestroyed(this, 'doTimeup'); + if (this.isTimeup()) return; + if (!this.isStopped()) this.stop(); + this._timeup = true; + return this.fire(this.timeup); + }; + + // TODO: improve. + + /** + * ## GameTimer.setStagerProperty + * + * Sets the value of stagerProperty + * + * @param {string} property The property to set + * + * @see GameTimer.stagerProperty + * @see GameTimer.getStagerProperty + */ + GameTimer.prototype.setStagerProperty = function(property) { + checkDestroyed(this, 'setStagerProperty'); + if ('string' === typeof property) { + throw new TypeError('GameTimer.setStageProperty: property must ' + + 'be string. Found: ' + property); + } + this.stagerProperty = property; + }; + + /** + * ## GameTimer.getStagerProperty + * + * Returns the current value of the stager property + * + * @return {string} stagerProperty + * + * @see GameTimer.setStagerProperty + */ + GameTimer.prototype.getStagerProperty = function() { + checkDestroyed(this, 'getStagerProperty'); + return this.stagerProperty; + }; + + /** + * ### GameTimer.getStepOptions + * + * Makes an object out of step properties 'timer' and 'timeup' + * + * Looks up property 'timer' in the game plot. If it is not an object, + * makes it an object with property 'milliseconds'. Makes sure + * 'milliseconds' is a number, otherwise returns null. + * + * If property 'timeup' is not defined, it looks it up in the game plot. + * + * If property 'update' is not defined, it sets it equals to 'milliseconds'. + * + * For example: + * + * ```javascript + * { + * milliseconds: 2000, + * update: 2000, + * timeup: function() {} + * // Additional properties as specified. + * } + * ``` + * + * @param {mixed} step Optional. Game step. Default current game stepx + * @param {string} prop Optional. The name of the property to look up + * in the plot containing 'timer' info. Default: `this.stagerProperty` + * + * @return {object} options Validated configuration object, or NULL + * if no timer info is found for current step + */ + GameTimer.prototype.getStepOptions = function(step, prop) { + var timer, timeup; + checkDestroyed(this, 'getStepOptions'); + step = 'undefined' !== typeof step ? + step : this.node.game.getCurrentGameStage(); + prop = prop || this.getStagerProperty(); + + timer = this.node.game.plot.getProperty(step, prop); + if (null === timer) return null; + + // If function, it can return a full object, + // a function, or just the number of milliseconds. + if ('function' === typeof timer) { + timer = timer.call(this.node.game); + if (null === timer) return null; + } + else if ('object' === typeof timer) { + // Manual clone. + timer = { + milliseconds: timer.milliseconds, + update: timer.update, + timeup: timer.timeup, + hooks: timer.hooks + }; + if ('function' === typeof timer.milliseconds) { + timer.milliseconds = timer.milliseconds.call(this.node.game); + } + } + + if ('function' === typeof timer) timer = timer.call(this.node.game); + if ('number' === typeof timer) timer = { milliseconds: timer }; + + if ('object' !== typeof timer || + 'number' !== typeof timer.milliseconds || + timer.milliseconds < 0) { + + this.node.warn('GameTimer.getStepOptions: invalid value for ' + + 'milliseconds. Found: ' + timer.milliseconds); + return null; + } + + // Make sure update and timer are the same. + if ('undefined' === typeof timer.update) { + timer.update = timer.milliseconds; + } + + if ('undefined' === typeof timer.timeup) { + timeup = this.node.game.plot.getProperty(step, 'timeup'); + if (timeup) timer.timeup = timeup; + } + + return timer; + }; + + // ## Helper methods. + + /** + * ### updateCallback + * + * Updates the timer object + * + * @param {GameTimer} that The game timer instance + * + * @return {boolean} FALSE if timer ran out, TRUE otherwise + */ + function updateCallback(that) { + var i; + that.status = GameTimer.RUNNING; + that.timePassed += that.update; + that.timeLeft -= that.update; + that.updateStart = J.now(); + // Fire custom hooks from the latest to the first if any. + for (i = that.hooks.length; i > 0; i--) { + that.fire(that.hooks[(i-1)]); + } + // Fire Timeup Event + if (that.timeLeft <= 0) { + that.doTimeup(); + return false; + } + + return true; + } + + /** + * ### checkInitialized + * + * Check whether the timer has a valid initialized state + * + * @param {GameTimer} that The game timer instance + * + * @return {string|null} Returns null if timer is in valid, + * state, or an error string otherwise. + */ + function checkInitialized(that) { + if ('number' !== typeof that.milliseconds) { + return 'milliseconds must be a number. Found ' + that.milliseconds; + } + if (that.update > that.milliseconds) { + return 'update cannot be larger than milliseconds'; + } + return null; + } + + /** + * ### checkDestroyed + * + * Check whether the timer has been destroyed and throws an error if so + * + * @param {GameTimer} that The game timer instance + * @param {string} method The name of the method invoking it + */ + function checkDestroyed(that, method) { + if (that.status === GameTimer.DESTROYED) { + throw new Error('GameTimer.' + method + ': gameTimer ' + + 'marked as destroyed: ' + that.name); + } + } + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Matcher + * Copyright(c) 2020 Stefano Balietti + * MIT Licensed + * + * Class handling the creation of tournament schedules. + * + * http://www.nodegame.org + * --- + */ +(function(exports, node) { + + var J = node.JSUS; + var Roler = node.Roler; + + // Object containing methods to fetch a match in the requested format. + // Will be initialized later. + var fetchMatch; + + exports.Matcher = Matcher; + + // ## Static methods. + + /** + * ### Matcher.bye + * + * Symbol used to complete matching when partner is missing + * + * @see Matcher.matches + + */ + Matcher.bye = -1; + + /** + * ### Matcher.missingId + * + * Symbol assigned to matching number without valid id + * + * @see Matcher.resolvedMatches + * @see Roler.missingId + */ + Matcher.missingId = 'bot'; + + /** + * ## Matcher.randomAssigner + * + * Assigns ids to positions randomly. + * + * @param {array} ids The ids to assign + * + * @return The sorted array + * + * @see JSUS.shuffle + */ + Matcher.randomAssigner = function(ids) { + return J.shuffle(ids); + }; + + /** + * ### Matcher.linearAssigner + * + * Assigns ids to positions linearly. + * + * @param {array} ids The ids to assign + * + * @return The sorted array + */ + Matcher.linearAssigner = function(ids) { + return J.clone(ids); + }; + + /** + * ## Matcher constructor + * + * Creates a new Matcher object + * + * @param {object} options Optional. Configuration options + */ + function Matcher(options) { + + options = options || {}; + + /** + * ### Matcher.x + * + * The row-index of the last returned match by Matcher.getMatch + * + * @see Matcher.getMatch + */ + this.x = null; + + /** + * ### Matcher.y + * + * The column-index of the last returned match by Matcher.getMatch + * + * @see Matcher.getMatch + */ + this.y = null; + + /** + * ### Matcher.matches + * + * Nested array of matches (with position-numbers) + * + * Nests a new array for each round, and within each round + * individual matches are also array. For example: + * + * ```javascript + * + * // Matching array. + * [ + * + * // First round. + * [ [ p1, p2 ], [ p3, p4 ], ... ], + * + * // Second round. + * [ [ p2, p3 ], [ p4, p1 ], ... ], + * + * // Further rounds. + * ]; + * ``` + * + * @see Matcher.setMatches + */ + this.matches = null; + + /** + * ### Matcher.resolvedMatches + * + * Nested array of matches (with id-strings) + * + * Exactly Matcher.matches, but with with ids instead of numbers + * + * This method is used both by getMatch and getMatchObject (if + * a single match is requested). + * + * @see Matcher.matches + * @see Matcher.resolvedMatchesObj + * @see Matcher.resolvedMatchesById + * @see Matcher.setIds + * @see Matcher.setAssignerCb + * @see Matcher.match + */ + this.resolvedMatches = null; + + /** + * ### Matcher.resolvedMatchesObj + * + * Array of maps id to partner, one map per round + * + * ```javascript + * + * // Matching array. + * [ + * + * // First round. + * { p1: 'p2', p2: 'p1', p3: 'p4', p4: 'p3', ... }, + * + * // Second round. + * { p2: 'p3', p3: 'p2', p4: 'p1', p1: 'p4', ... }, + * + * // Further rounds. + * ]; + * ``` + * + * @see Matcher.resolvedMatches + * @see Matcher.resolvedMatchesById + * @see Matcher.setIds + * @see Matcher.match + */ + this.resolvedMatchesObj = null; + + /** + * ### Matcher.resolvedMatchesById + * + * Maps ids to a sequence of matches + * + * ```javascript + * + * // Matching object. + * { + * + * // All rounds. + * p1: [ 'p2', 'p4', ... ], + * p2: [ 'p1', 'p3', ... ], + * p3: [ 'p4', 'p2', ... ], + * p4: [ 'p3', 'p1', ... ] + * ... + * + * }; + * ``` + * + * @see Matcher.resolvedMatches + * @see Matcher.resolvedMatchesObj + * @see Matcher.setIds + * @see Matcher.match + */ + this.resolvedMatchesById = null; + + /** + * ### Matcher.ids + * + * Array ids to match + * + * @see Matcher.setIds + */ + this.ids = null; + + /** + * ### Matcher.ids + * + * Array mapping each ordinal position to an id + * + * @see Matcher.ids + * @see Matcher.assignerCb + */ + this.assignedIds = null; + + /** + * ### Matcher.idsMap + * + * Map ids to match + * + * @see Matcher.setIds + */ + this.idsMap = null; + + /** + * ### Matcher.assignedIdsMap + * + * Map ids to ordinal position in matches + * + * @see Matcher.idsMap + * @see Matcher.assignedIds + */ + this.assignedIdsMap = null; + + /** + * ### Matcher.assignerCb + * + * Callback that assigns ids to positions + * + * An assigner callback must take as input an array of ids, + * reorder them according to some criteria, and return it. + * The order of the items in the returned array will be used to + * match the numbers in the `matches` array. + * + * @see Matcher.ids + * @see Matcher.matches + * @see Matcher.assignedIds + */ + this.assignerCb = Matcher.randomAssigner; + + /** + * ## Matcher.missingId + * + * An id used to replace missing players ids + */ + this.missingId = Matcher.missingId; + + /** + * ## Matcher.missingId + * + * An id used by matching algorithms to complete unfinished matches + */ + this.bye = Matcher.bye; + + /** + * ## Matcher.doObjLists + * + * Flag that obj lists should be created when `match` is invoked + * + * @see Matcher.resolvedMatchesObj + * @see Matcher.matcher + */ + this.doObjLists = true; + + /** + * ## Matcher.doIdLists + * + * Flag that id lists should be created when `match` is invoked + * + * @see Matcher.resolvedMatchesById + * @see Matcher.matcher + */ + this.doIdLists = true; + + /** + * ## Matcher.doRoles + * + * Flag that roles should be assigned when `match` is invoked + * + * Requires roles to be set, otherwise an error is thrown + * + * @see Matcher.roles + * @see Matcher.roler + * @see Matcher.matcher + */ + this.doRoles = false; + + /** + * ## Matcher.roler + * + * Handles assigning roles to matches + * + * If null here, is initialized by `init` if doRoles is TRUE. + * + * @see Matcher.doRoles + * @see Matcher.init + */ + this.roler = options.roler || null; + + /** + * ## Matcher.roles + * + * Roles map created if `doRoles` is TRUE + * + * @see Matcher.doRoles + * @see Matcher.roler + * @see Matcher.matcher + */ + this.roler = options.roler || null; + + // Init. + this.init(options); + } + + /** + * ### Matcher.init + * + * Inits the Matcher instance + * + * @param {object} options + */ + Matcher.prototype.init = function(options) { + options = options || {}; + + if (options.assignerCb) this.setAssignerCb(options.assignerCb); + if (options.ids) this.setIds(options.ids); + if (options.bye) this.bye = options.bye; + if (options.missingId) this.missingId = options.missingId; + + if (null === options.x) this.x = null; + else if ('number' === typeof options.x) { + if (options.x < 0) { + throw new Error('Matcher.init: options.x cannot be negative.' + + 'Found: ' + options.x); + } + this.x = options.x; + } + else if (options.x) { + throw new TypeError('Matcher.init: options.x must be number, ' + + 'null or undefined. Found: ' + options.x); + } + + if (null === options.y) this.y = null; + else if ('number' === typeof options.y) { + if (options.y < 0) { + throw new Error('Matcher.init: options.y cannot be negative.' + + 'Found: ' + options.y); + } + this.y = options.y; + } + else if (options.y) { + throw new TypeError('Matcher.init: options.y must be number, ' + + 'null or undefined. Found: ' + options.y); + } + + if (options.doRoles || options.roles) { + if (!this.roler) this.roler = new Roler(); + this.roler.init({ + missingId: this.missingId, + roles: options.roles + }); + this.doRoles = true; + } + else if ('undefined' !== typeof options.doRoles) { + this.doRoles = !!options.doRoles; + } + + if ('undefined' !== typeof options.doObjLists) { + this.doObjLists = !!options.doObjLists; + } + + if ('undefined' !== typeof options.doIdLists) { + this.doIdLists = !!options.doIdLists; + } + }; + + /** + * ### Matcher.generateMatches + * + * Creates a matches array according to the chosen scheduling algorithm + * + * Throws an error if the selected algorithm is not found. + * + * @param {string} alg The chosen algorithm. Available: 'roundrobin', + * 'random' + * + * @return {array} The array of matches + */ + Matcher.prototype.generateMatches = function(alg) { + var matches; + if ('string' !== typeof alg) { + throw new TypeError('Matcher.generateMatches: alg must be ' + + 'string. Found: ' + alg); + } + alg = alg.toLowerCase(); + if (alg === 'roundrobin' || alg === 'round_robin' || + alg === 'random' || alg === 'random_pairs' ) { + + matches = pairMatcher(alg, arguments[1], arguments[2]); + } + else { + throw new Error('Matcher.generateMatches: unknown algorithm: ' + + alg); + } + + this.setMatches(matches); + return matches; + }; + + /** + * ### Matcher.setMatches + * + * Sets the matches for current instance + * + * Resets resolvedMatches and resolvedMatchesObj to null. + * + * @param {array} The array of matches + * + * @see this.matches + */ + Matcher.prototype.setMatches = function(matches) { + if (!J.isArray(matches) || !matches.length) { + throw new TypeError('Matcher.setMatches: matches must be a ' + + 'non-empty array. Found: ' + matches); + } + this.matches = matches; + resetResolvedData(this); + }; + + /** + * ### Matcher.getMatches + * + * Returns the matches for current instance + * + * @return {array|null} The array of matches (NULL if not yet set) + * + * @see this.matches + */ + Matcher.prototype.getMatches = function() { + return this.matches; + }; + + /** + * ### Matcher.setIds + * + * Sets the ids to be used for the matches + * + * @param {array} ids Array containing the id of the matches + * + * @see Matcher.ids + * @see Matcher.idsMap + */ + Matcher.prototype.setIds = function(ids) { + var i, len; + if (!J.isArray(ids) || !ids.length) { + throw new TypeError('Matcher.setIds: ids must be a non-empty ' + + 'array. Found: ' + ids); + } + // Keep track of all ids. + this.idsMap = {}; + i = -1, len = ids.length; + for ( ; ++i < len ; ) { + // TODO: validate? Duplicated ids are fine? + this.idsMap[ids[i]] = true; + } + this.ids = ids; + resetResolvedData(this); + }; + + /** + * ### Matcher.getIds + * + * Returns the ids used to created the matching + * + * @return {array} ids Ids in use + * + * @see Matcher.ids + */ + Matcher.prototype.getIds = function() { + return this.ids; + }; + + /** + * ### Matcher.assignIds + * + * Calls the assigner callback to assign ids to positions + * + * Ids can be overwritten by parameter. If no ids are found, + * they will be automatically generated, provided that matches + * have been generated first. + * + * @param {array} ids Optional. Array containing the id of the matches + * to pass to Matcher.setIds + * + * @see Matcher.ids + * @see Matcher.setIds + * @see Matcher.assignedIds + * @see Matcher.assignedIdsMap + */ + Matcher.prototype.assignIds = function(ids) { + var i, len; + if ('undefined' !== typeof ids) this.setIds(ids); + if (!J.isArray(this.ids) || !this.ids.length) { + if (!J.isArray(this.matches) || !this.matches.length) { + throw new TypeError('Matcher.assignIds: no ids and no ' + + 'matches found.'); + } + this.ids = J.seq(0, this.matches.length -1, 1, function(i) { + return '' + i; + }); + } + this.assignedIds = this.assignerCb(this.ids); + // Map all ids to its position. + this.assignedIdsMap = {}; + i = -1, len = this.assignedIds.length; + for ( ; ++i < len ; ) { + this.assignedIdsMap[this.assignedIds[i]] = i; + } + }; + + /** + * ### Matcher.setAssignerCb + * + * Specify a callback to be used to assign existing ids to positions + * + * @param {function} cb The assigner cb + * + * @see Matcher.ids + * @see Matcher.matches + * @see Matcher.assignerCb + */ + Matcher.prototype.setAssignerCb = function(cb) { + if ('function' !== typeof cb) { + throw new TypeError('Matcher.setAssignerCb: cb must be ' + + 'function. Found: ' + cb); + } + this.assignerCb = cb; + }; + + /** + * ### Matcher.match + * + * Substitutes the ids to the matches + * + * Populates the indexes: + * + * - `resolvedMatches`, + * - `resolvedMatchesObj`, + * - `resolvedMatchesById` + * + * If the matches array is not already set, an error is thrown. + * + * If the ids have not been assigned, it does automatic assignment. + * + * @param {boolean|array} assignIds Optional. A flag to force to + * re-assign existing ids, or an an array containing new ids to + * assign. + * + * @see Matcher.assignIds + * @see Matcher.resolvedMatchesObj + * @see Matcher.resolvedMatches + * + * TODO: creates two lists of matches with bots and without. + */ + Matcher.prototype.match = function(assignIds) { + var i, lenI, j, lenJ, pair; + var matched, matchedObj, matchedId, id1, id2; + var roles, rolesObj, idRolesObj, r1, r2; + + if (!J.isArray(this.matches) || !this.matches.length) { + throw new Error('Matcher.match: no matches found'); + } + + // Assign/generate ids if not done before. + if (!this.assignedIds || assignIds) { + if (J.isArray(assignIds)) this.assignIds(assignIds); + else this.assignIds(); + } + + // Parse the matches array and creates two data structures + // where the absolute position becomes the player id. + i = -1, lenI = this.matches.length; + matched = new Array(lenI); + matchedObj = this.doObjLists ? new Array(lenI) : null; + matchedId = this.doIdLists ? {} : null; + if (this.doRoles) { + roles = new Array(lenI); + rolesObj = new Array(lenI); + idRolesObj = new Array(lenI); + } + else { + roles = null; + rolesObj = null; + idRolesObj = null; + } + for ( ; ++i < lenI ; ) { + j = -1, lenJ = this.matches[i].length; + matched[i] = new Array(lenJ); + if (this.doObjLists) matchedObj[i] = {}; + if (this.doRoles) { + roles[i] = new Array(lenJ); + rolesObj[i] = new Array(lenJ); + idRolesObj[i] = new Array(lenJ); + } + for ( ; ++j < lenJ ; ) { + id1 = null, id2 = null; + pair = this.matches[i][j]; + // Resolve matches. + id1 = importMatchItem(i, j, + pair[0], + this.assignedIds, + this.missingId); + id2 = importMatchItem(i, j, + pair[1], + this.assignedIds, + this.missingId); + // Create resolved matches: + // Array. + matched[i][j] = [id1, id2]; + // Obj. + if (this.doObjLists) { + matchedObj[i][id1] = id2; + matchedObj[i][id2] = id1; + } + // By Id. + if (this.doIdLists) { + if (!matchedId[id1]) matchedId[id1] = new Array(lenI); + if (!matchedId[id2]) matchedId[id2] = new Array(lenI); + matchedId[id1][i] = id2; + matchedId[id2][i] = id1; + } + // Roles. + if (this.doRoles) { + roles[i][j] = this.roler.rolify(matched[i][j], i, j); + // TODO: this code is repeated in Roler.rolifyAll. + // make it one! + r1 = roles[i][j][0]; + r2 = roles[i][j][1]; + rolesObj[i][j] = {}; + if (r1 !== r2) { + rolesObj[i][j][r1] = id1; + rolesObj[i][j][r2] = id2; + } + else { + rolesObj[i][j][r1] = [ id1, id2 ]; + } + idRolesObj[i][j] = {}; + idRolesObj[i][j][id1] = r1; + idRolesObj[i][j][id2] = r2; + } + } + } + // Substitute matching-structure. + this.resolvedMatches = matched; + this.resolvedMatchesObj = matchedObj; + this.resolvedMatchesById = matchedId; + this.roles = roles; + this.rolesObj = rolesObj; + if (this.doRoles) { + this.roler.setRolifiedMatches(roles, false); + this.roler.setRole2IdMatches(rolesObj, false); + this.roler.setId2RoleMatches(idRolesObj, false); + } + // Set getMatch indexes to 0. + this.x = null; + this.y = null; + }; + + /** + * ### Matcher.hasNext + * + * Returns TRUE if there is next match to be returned by getMatch + * + * @param {number} x Optional. The x-th round. Default: Matcher.x + * @param {number} y Optional. The y-th match within the x-th round + * Default: Matcher.y + * + * @return {bolean} TRUE, if there exists a next match + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.resolvedMatches + * @see hasOrGetNext + */ + Matcher.prototype.hasNext = function(x, y) { + return hasOrGetNext.call(this, 'hasNext', 0, x, y); + }; + + /** + * ### Matcher.getMatch + * + * Returns the next match, or the specified match + * + * @param {number} x Optional. The x-th round. Default: Matcher.x + * @param {number} y Optional. The y-th match within the x-th round. + * Default: Matcher.y + * + * @return {array} The next or requested match, or null if not found + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.resolvedMatches + * @see hasOrGetNext + */ + Matcher.prototype.getMatch = function(x, y) { + return hasOrGetNext.call(this, 'getMatch', 1, x, y); + }; + + /** + * ### Matcher.getMatchFor + * + * Returns the id/s of the next or the x-th match for the specified id + * + * If id lists are not generated (see `Matcher.doIdLists) an + * error is thrown. + * + * @param {string} id The id to get the matches for + * @param {number} x Optional. The x-th round. Default: Matcher.x + * + * @return {string|array} The next or requested match, or null if not found + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.doIdLists + * @see Matcher.resolvedMatches + * @see hasOrGetNext + */ + Matcher.prototype.getMatchFor = function(id, x) { + var out; + if ('string' !== typeof id) { + throw new TypeError('Matcher.getMatchFor: id must be string. ' + + 'Found:' + id); + } + if (!this.resolvedMatchesById) { + throw new Error('Matcher.getMatchFor: no id-based matches found.'); + } + out = this.resolvedMatchesById[id]; + if (!out) return null; + if ('undefined' === typeof x) return out; + if ('number' === typeof x) { + if (x >= 0 && !isNaN(x)) return x > (out.length -1) ? null : out[x]; + } + throw new TypeError('Matcher.getMatchFor: x must be a positive ' + + 'number or undefined. Found: ' + x); + }; + + /** + * ### Matcher.getMatchObject + * + * Returns all the matches of the next or requested round as key-value pairs + * + * If object lists are not generated (see `Matcher.doObjLists) an + * error is thrown. + * + * @param {number} x Optional. The x-th round. Default: Matcher.x + * @param {number} y Optional. The y-th match within the x-th round. + * Default: Matcher.y + * + * @return {object|null} The next or requested match, or null if not found + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.doObjLists + * @see Matcher.resolvedMatchesObj + */ + Matcher.prototype.getMatchObject = function(x, y) { + if (!this.resolvedMatchesObj) { + throw new Error('Matcher.getMatchObject: no obj matches found.'); + } + return hasOrGetNext.call(this, 'getMatchObject', 3, x, y); + }; + + /** + * ### Matcher.normalizeRound + * + * Returns the round index given the current number of matches + * + * For example, if the are only 10 matches repeated in cycle, + * but the game has 20 rounds, round 13th will have normalized + * round index equal to 3. + * + * Important! Matches are 0-based, but rounds are 1-based. This + * method takes care of it. + * + * @param {number} round The round to normalize + * + * @return {object} The next or requested match, or null if not found + * + * @see Matcher.x + * @see Matcher.matches + */ + Matcher.prototype.normalizeRound = function(round) { + if (!this.matches) { + throw new TypeError('Matcher.normalizeRound: no matches found.'); + } + if ('number' !== typeof round || isNaN(round) || round < 1) { + throw new TypeError('Matcher.normalizeRound: round must be a ' + + 'number > 0. Found: ' + round); + } + return (round-1) % this.matches.length; + }; + + /** + * ### Matcher.replaceId + * + * Replaces an id with a new one in all matches + * + * @param {string} oldId The id to be replaced + * @param {string} newId The replacing id + * + * @return {boolean} TRUE, if the oldId was found and replaced + * + * @see MatcherManager.replaceId + * @see Roler.replaceId + */ + Matcher.prototype.replaceId = function(oldId, newId) { + var m; + var i, len, j, lenJ, h, lenH; + var rowFound; + if ('string' !== typeof oldId) { + throw new TypeError('Matcher.replaceId: oldId should be string. ' + + 'Found: ' + oldId); + } + if ('string' !== typeof newId || newId.trim() === '') { + throw new TypeError('Matcher.replaceId: newId should be a ' + + 'non-empty string. Found: ' + newId); + } + + // No id was assigned yet. + if (!this.resolvedMatches) return false; + + // IdsMap. + m = this.idsMap[oldId]; + if ('undefined' === typeof m) return false; + + this.idsMap[newId] = true; + delete this.idsMap[oldId]; + + // Ids. + m = this.ids; + i = -1, len = m.length; + for ( ; ++i < len ; ) { + if (m[i] === oldId) { + m[i] = newId; + break; + } + } + + // AssignedIds and AssignedIdsMap. + m = this.assignedIdsMap; + m[newId] = m[oldId]; + delete m[oldId]; + this.assignedIds[m[newId]] = newId; + + // Update resolvedMatches. + m = this.resolvedMatches; + if (!m) return true; + + i = -1, len = m.length; + for ( ; ++i < len ; ) { + j = -1, lenJ = m[i].length; + rowFound = false; + for ( ; ++j < lenJ ; ) { + h = -1, lenH = m[i][j].length; + for ( ; ++h < lenH ; ) { + if (m[i][j][h] === oldId) { + m[i][j][h] = newId; + rowFound = true; + break; + } + } + if (rowFound) break; + } + } + + // Update resolvedMatchesObj. + m = this.resolvedMatchesObj; + + i = -1, len = m.length; + for ( ; ++i < len ; ) { + for (j in m[i]) { + if (m[i].hasOwnProperty(j)) { + if (j === oldId) { + // Do the swap. + m[i][newId] = m[i][oldId]; + m[i][m[i][oldId]] = newId; + delete m[i][oldId]; + break; + } + } + } + } + + // Update resolvedMatchesById. + m = this.resolvedMatchesById; + for (i in m) { + if (m.hasOwnProperty(i)) { + if (i === oldId) { + m[newId] = m[oldId]; + delete m[oldId]; + } + else { + lenJ = m[i].length; + // THIS OPTIMIZATION DOES NOT SEEM TO WORK. + // In fact, there might be more matches with the same + // partner in sequence. + // And also if === 1, it should be checked. + // if (lenJ == 1) { + // m[i][0] = newId; + // } + // else if (lenJ === 2) { + // if (m[i][0] === oldId) m[i][0] = newId; + // else m[i][1] = newId; + // } + // else { + j = -1; + for ( ; ++j < lenJ ; ) { + if (m[i][j] === oldId) { + m[i][j] = newId; + } + } + // } + } + } + } + + return true; + }; + + /** + * ### Matcher.clear + * + * Clears the matcher as it would be a newly created object + */ + Matcher.prototype.clear = function() { + this.x = null; + this.y = null; + this.matches = null; + this.resolvedMatches = null; + this.resolvedMatchesObj = null; + this.ids = null; + this.assignedIds = null; + this.idsMap = null; + this.assignedIdsMap = null; + this.assignerCb = Matcher.randomAssigner; + this.missingId = Matcher.missingId; + this.bye = Matcher.bye; + }; + + // ## Helper methods. + + /** + * ### importMatchItem + * + * Handles importing items from the matches array + * + * Items in matches array must be numbers or strings. If numbers + * they are translated into an id using the supplied map, otherwise + * they are considered as already an id. + * + * Items that are not numbers neither strings will throw an error. + * + * @param {number} i The row-id of the item + * @param {number} j The position in the row of the item + * @param {string|number} item The item to check + * @param {array} map The map of positions to ids + * @param {string} miss The id of number that cannot be resolved in map + * + * @return {string} The resolved id of the item + */ + function importMatchItem(i, j, item, map, miss) { + if ('number' === typeof item) { + return 'undefined' !== typeof map[item] ? map[item] : miss; + } + else if ('string' === typeof item) { + return item; + } + throw new TypeError('Matcher.match: items can be only string or ' + + 'number. Found: ' + item + ' at position ' + + i + ',' + j); + } + + /** + * ### resetResolvedData + * + * Resets resolved data of a matcher object + * + * @param {Matcher} matcher The matcher to reset + */ + function resetResolvedData(matcher) { + matcher.resolvedMatches = null; + matcher.resolvedMatchesObj = null; + matcher.resolvedMatchesById = null; + matcher.assignedIds = null; + matcher.assignedIdsMap = null; + } + + /** + * ### pairMatcherOld + * + * Creates tournament schedules for different algorithms + * + * @param {string} alg The name of the algorithm + * @param {number|array} n The number of participants (>1) or + * an array containing the ids of the participants + * @param {object} options Optional. Configuration object + * contains the following options: + * + * - bye: identifier for dummy competitor. Default: -1. + * - skypeBye: flag whether players matched with the dummy + * competitor should be added or not. Default: true. + * - rounds: number of rounds to repeat matching. Default: + * - cycle: if there are more rounds than possible combinations + * this option specifies how to fill extra rounds. Available + * settings: + * + * - 'repeat': repeats all available matches (default) + * - 'repeat_invert': repeats all available matches, but inverts + * the position of ids in the match + * - 'mirror': repeats all available matches in mirrored order. + * - 'mirror_invert': repeats all available matches in mirrored + * order and also inverts the position of the ids in the match + * + * @return {array} matches The matches according to the algorithm + */ + function pairMatcher(alg, n, options) { + var ps, matches, bye; + var i, lenI, j, lenJ, jj; + var id1, id2; + var roundsLimit, cycle, cycleI, skipBye; + var fixedRolesNoSameMatch; + + if ('number' === typeof n && n > 1) { + ps = J.seq(0, (n-1)); + } + else if (J.isArray(n) && n.length > 1) { + ps = n.slice(); + n = ps.length; + } + else { + throw new TypeError('pairMatcher.' + alg + ': n must be ' + + 'number > 1 or array of length > 1.'); + } + options = options || {}; + + bye = 'undefined' !== typeof options.bye ? options.bye : -1; + skipBye = options.skipBye || false; + + // Make sure we have even numbers. + if ((n % 2) === 1) { + ps.push(bye); + n += 1; + } + + // Does not work. + if (options.fixedRoles && (options.canMatchSameRole === false)) { + fixedRolesNoSameMatch = true; + } + + // Limit rounds. + if ('number' === typeof options.rounds) { + if (options.rounds <= 0) { + throw new Error('pairMatcher.' + alg + ': options.rounds ' + + 'must be a positive number or undefined. ' + + 'Found: ' + options.rounds); + } + if (options.rounds > (n-1)) { + throw new Error('pairMatcher.' + alg + ': ' + + 'options.rounds cannot be greater than ' + + (n-1) + '. Found: ' + options.rounds); + } + // Here roundsLimit does not depend on n (must be smaller). + roundsLimit = options.rounds; + } + else if (fixedRolesNoSameMatch) { + roundsLimit = Math.floor(n/2); + } + else { + roundsLimit = n-1; + } + + if ('undefined' !== typeof options.cycle) { + cycle = options.cycle; + if (cycle !== 'mirror_invert' && cycle !== 'mirror' && + cycle !== 'repeat_invert' && cycle !== 'repeat') { + + throw new Error('pairMatcher.' + alg + ': options.cycle ' + + 'must be equal to "mirror"/"mirror_invert", ' + + '"repeat"/"repeat_invert" or undefined . ' + + 'Found: ' + options.cycle); + } + + matches = new Array(roundsLimit*2); + } + else { + matches = new Array(roundsLimit); + } + + i = -1, lenI = roundsLimit; + for ( ; ++i < lenI ; ) { + // Shuffle list of ids for random. + if (alg === 'random') ps = J.shuffle(ps); + // Create a new array for round i. + lenJ = n / 2; + matches[i] = skipBye ? new Array(lenJ-1) : new Array(lenJ); + // Check if new need to cycle. + if (cycle) { + if (cycle === 'mirror' || cycle === 'mirror_invert') { + cycleI = (roundsLimit*2) -i -1; + } + else { + cycleI = i+roundsLimit; + } + matches[cycleI] = skipBye ? + new Array(lenJ-1) : new Array(lenJ); + } + // Counter jj is updated only if not skipBye, + // otherwise we create holes in the matches array. + jj = j = -1; + for ( ; ++j < lenJ ; ) { + if (fixedRolesNoSameMatch) { + id1 = ps[j*2]; + id2 = ps[((i*2)+(j*2)+1) % n]; + } + else { + id1 = ps[j]; + id2 = ps[n - 1 - j]; + } + if (!skipBye || (id1 !== bye && id2 !== bye)) { + jj++; + // Insert match. + matches[i][jj] = [ id1, id2 ]; + // Insert cycle match (if any). + if (cycle === 'repeat') { + matches[cycleI][jj] = [ id1, id2 ]; + } + else if (cycle === 'repeat_invert') { + matches[cycleI][jj] = [ id2, id1 ]; + } + else if (cycle === 'mirror') { + matches[cycleI][jj] = [ id1, id2 ]; + } + else if (cycle === 'mirror_invert') { + matches[cycleI][jj] = [ id2, id1 ]; + } + } + } + // Permutate for next round. + if (!fixedRolesNoSameMatch) ps.splice(1, 0, ps.pop()); + } + return matches; + } + + /** + * ## fetchMatch + * + * Maps method names to a return function to execute in case of success + * + * - 0: hasNext -> returns true + * - 1: getMatch -> returns an array, or array of arrays + * - 2: getMatchFor -> returns a string + * - 3: getMatchObject -> returns an object + * + * @see hasOrGetNext + */ + fetchMatch = [ + // hasNext. + function() { + return true; + }, + // getMatch. + function(x, y) { + return 'number' === typeof y ? + this.resolvedMatches[x][y] : this.resolvedMatches[x]; + }, + // getMatchFor. + function(x, y, id) { + if ('number' === typeof x && 'number' === typeof y) { + return this.resolvedMatchesById[id][x]; + } + return this.resolvedMatchesById[id]; + }, + // getMatchObject. + function(x, y) { + var match, res; + if ('number' === typeof y) { + res = {}; + match = this.resolvedMatches[x][y]; + res[match[0]] = match[1]; + res[match[1]] = match[0]; + return res; + } + return this.resolvedMatchesObj[x]; + } + ]; + + /** + * ### hasOrGetNext + * + * Returns TRUE or the match if there is next match + * + * If in `get` mode it also updates the x and y indexes. + * + * @param {string} m The name of the method invoking it + * @param {boolean} get TRUE, if the method should return the match + * @param {number} x Optional. The x-th round. Default: Matcher.x + * @param {number} y Optional. The y-th match within the x-th round + * Default: Matcher.y + * @param {string} id Optional. Used by method getMatchFor + * + * @return {boolean|array|null} TRUE or the next match (if found), + * FALSE or null (if not found) + * + * @see Matcher.x + * @see Matcher.y + * @see Matcher.resolvedMatches + * @see fetchMatch + */ + function hasOrGetNext(m, mod, x, y, id) { + var nRows, nCols; + + // Check if there is any match yet. + if (!J.isArray(this.resolvedMatches) || !this.resolvedMatches.length) { + throw new Error('Matcher.' + m + ': no resolved matches found.'); + } + + nRows = this.resolvedMatches.length - 1; + + // No x, No y get the next match. + if ('undefined' === typeof x) { + // Check both x and y. + if ('undefined' !== typeof y) { + throw new Error('Matcher.' + m + + ': cannot specify y without x.'); + } + + // No match was ever requested. + if (null === this.x) { + this.x = 0; + this.y = 0; + return fetchMatch[mod].call(this, 0, 0, id); + } + + x = this.x; + y = this.y + 1; + if (x <= nRows) { + nCols = this.resolvedMatches[x].length - 1; + if (y <= nCols) { + if (mod) { + this.x = x; + this.y = y; + return fetchMatch[mod].call(this, x, y, id); + // return this.resolvedMatches[x][y]; + } + else { + return true; + } + } + else { + x = x + 1; + y = 0; + if (mod) { + this.x = x; + this.y = y; + } + if (x <= nRows) { + return fetchMatch[mod].call(this, x, y, id); + // return mod ? this.resolvedMatches[x][y] : true; + } + else { + return mod ? null : false; + } + } + } + else { + return mod ? null : false; + } + } + // End undefined x. + + // Validate x. + if ('number' !== typeof x) { + throw new TypeError('Matcher.' + m + ': x must be number ' + + 'or undefined. Found: ' + x); + } + else if (x < 0 || isNaN(x)) { + throw new Error('Matcher.' + m + ': x cannot be negative or NaN. ' + + 'Found: ' + x); + } + + if (x > nRows) { + if (mod) { + this.x = x; + this.y = 0; + return null; + } + else { + return false; + } + } + + // Default y (whole row). + if ('undefined' === typeof y) { + if (mod) { + this.x = x; + this.y = this.resolvedMatches[nRows].length; + // Return the whole row. + return fetchMatch[mod].call(this, x, y, id); + // return this.resolvedMatches[x]; + } + else { + return true; + } + } + + // Validate y. + if ('number' !== typeof y) { + throw new TypeError('Matcher.' + m + ': y must be number ' + + 'or undefined.'); + } + else if (y < 0 || isNaN(y)) { + throw new Error('Matcher.' + m + ': y cannot be negative or NaN. ' + + 'Found: ' + y); + } + + nCols = this.resolvedMatches[x].length - 1; + + // Valid x,y match. + if (y <= nCols) { + if (mod) { + this.x = x; + this.y = y; + return fetchMatch[mod].call(this, x, y); + // return this.resolvedMatches[x][y]; + } + else { + return true; + } + } + // Out of bound. + else { + if (mod) { + this.x = x; + this.y = y; + return null; + } + else { + return false; + } + } + } + + // ## Closure +})( + 'undefined' !== typeof node ? node : module.exports, + 'undefined' !== typeof node ? node : module.parent.exports +); + +/** + * # NodeGameClient + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * nodeGame: Online Real-Time Synchronous Experiments. + * + * http://nodegame.org + */ +(function(exports, parent) { + + "use strict"; + + // ## Exposing Class + exports.NodeGameClient = NodeGameClient; + + var ErrorManager = parent.ErrorManager, + EventEmitterManager = parent.EventEmitterManager, + GameMsgGenerator = parent.GameMsgGenerator, + Socket = parent.Socket, + Game = parent.Game, + Timer = parent.Timer, + constants = parent.constants; + + /** + * ## NodeGameClient constructor + * + * Creates a new NodeGameClient object + */ + function NodeGameClient() { + + this.info('node: loading.'); + + /** + * ### node.nodename + * + * The name of this node, used in logging output + * + * Default: 'ng' + */ + this.nodename = 'ng'; + + /** + * ### node.verbosity + * + * The minimum level for a log entry to be displayed as output + * + * Default: only warnings and errors are displayed + */ + this.verbosity = constants.verbosity_levels.warn; + + /** + * ### node.remoteVerbosity + * + * The minimum level for a log entry to be reported to the server + * + * Default: errors and warnings are reported + */ + this.remoteVerbosity = constants.verbosity_levels.error; + + /** + * ### node.remoteVerbosity + * + * Maps remotely logged messages to avoid infinite recursion + * + * In normal conditions this should always stay empty. + */ + this.remoteLogMap = {}; + + /** + * ### node.errorManager + * + * Catches run-time errors + * + * In debug mode errors are re-thrown. + */ + this.errorManager = new ErrorManager(this); + + /** + * ### node.events + * + * Instance of the EventEmitterManager class + * + * Takes care of emitting the events and calling the + * proper listener functions + * + * @see EventEmitter + */ + this.events = new EventEmitterManager(this); + + /** + * ### NodeGameClient.emit + * + * Emits an event locally on all registered event handlers + * + * The first parameter be the name of the event as _string_, + * followed by any number of parameters that will be passed to the + * handler callback. + * + * @see NodeGameClient.emitAsync + * @see EventEmitterManager.emit + */ + this.emit = this.events.emit; + + /** + * ### NodeGameClient.emitAsync + * + * Emits an event locally on all registered event handlers + * + * Unlike normal emit, it does not return a value. + * + * @see NodeGameClient.emit + * @see EventEmitterManager.emitSync + */ + this.emitAsync = this.events.emitAsync; + + /** + * ### NodeGameClient.on + * + * Registers an event listener on the active event emitter + * + * Different event emitters are active during the game. For + * example, before a game is started, e.g. in the init + * function of the game object, the `game` event emitter is + * active. Events registered with the `game` event emitter + * stay valid throughout the whole game. Listeners registered + * after the game is started will be removed after the game + * has advanced to its next stage or step. + * + * @param {string} event The name of the event + * @param {function} listener The callback function + * + * @see NodeGameClient.off + */ + this.on = function(event, listener) { + var ee; + ee = this.getCurrentEventEmitter(); + ee.on(event, listener); + }; + + /** + * ### NodeGameClient.once + * + * Registers an event listener that will be removed after its first call + * + * @param {string} event The name of the event + * @param {function} listener The callback function + * + * @see NodeGameClient.on + * @see NodeGameClient.off + */ + this.once = function(event, listener) { + var ee; + ee = this.getCurrentEventEmitter(); + ee.once(event, listener); + }; + + /** + * ### NodeGameClient.off + * + * Deregisters one or multiple event listeners + * + * @param {string} event The name of the event + * @param {function} listener The callback function + * + * @see NodeGameClient.on + * @see NodeGameClient.EventEmitter.remove + */ + this.off = function(event, func) { + return this.events.remove(event, func); + }; + + /** + * ### node.msg + * + * Factory of game messages + * + * @see GameMsgGenerator + */ + this.msg = new GameMsgGenerator(this); + + /** + * ### node.socket + * + * Instantiates the connection to a nodeGame server + * + * @see GameSocketClient + */ + this.socket = new Socket(this); + + /** + * ### node.session + * + * Contains a reference to all session variables + * + * Session variables can be saved and restored at a later stage + * + * @experimental + */ + // TODO: not used for now. + // this.session = new GameSession(this); + + /** + * ### node.player + * Instance of node.Player + * + * Contains information about the player + * + * @see PlayerList.Player + */ + this.player = { placeholder: true }; + + /** + * ### node.timer + * + * Instance of node.Timer + * + * @see Timer + */ + this.timer = new Timer(this); + + /** + * ### node.game + * + * Instance of node.Game + * + * @see Game + */ + this.game = new Game(this); + + /** + * ### node.store + * + * Makes the nodeGame session persistent, saving it + * to the browser local database or to a cookie + * + * @see shelf.js + */ + this.store = function() {}; + + /** + * ### node.conf + * + * A reference to the current nodegame configuration + * + * @see NodeGameClient.setup + */ + this.conf = {}; + + /** + * ### node.support + * + * A collection of features that are supported by the current browser + */ + this.support = {}; + + /** + * ### node._setup + * + * Object containing registered setup functions + * + * @see NodeGameClient.setup + * @see NodeGameClient.registerSetup + * + * @api private + */ + this._setup = {}; + + /** + * ### node._env + * + * Object containing registered environmental variables + * + * @see NodeGameClient.setup.env + * @see NodeGameClient.env + * + * @api private + */ + this._env = {}; + + // ## Configuration. + + // ### Setup functions. + this.addDefaultSetupFunctions(); + // ### Aliases. + this.addDefaultAliases(); + // ### Listeners. + this.addDefaultIncomingListeners(); + this.addDefaultInternalListeners(); + + this.info('node: object created.'); + } + + // ## Closure +})( + 'undefined' != typeof node ? node : module.exports + , 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Log + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * nodeGame logging module + */ +(function(exports, parent) { + + "use strict"; + + var NGC = parent.NodeGameClient; + var constants = parent.constants; + + var LOG = constants.target.LOG; + + var J = parent.JSUS; + + /** + * ### NodeGameClient.log + * + * Default nodeGame standard out, override to redirect + * + * Logs entries are displayed to the console if their level is + * smaller than `this.verbosity`. + * + * Logs entries are forwarded to the server if their level is + * smaller than `this.remoteVerbosity`. + * + * @param {string} txt The text to output + * @param {string} level Optional. The verbosity level of this log. + * Default: 'info' + * @param {string} prefix Optional. A text to display at the beginning of + * the log entry. Default: 'ng> ' + */ + NGC.prototype.log = function(txt, level, prefix) { + var numLevel, info; + if ('undefined' === typeof txt) return; + + level = level || 'info'; + numLevel = constants.verbosity_levels[level]; + + if (this.verbosity >= numLevel) { + // Add game stage manually (faster than toString()). + info = this.nodename + '@' + this.player.stage.stage + '.' + + this.player.stage.step + '.' + this.player.stage.round + + ' - ' + J.getTimeM() + ' > '; + if ('undefined' !== typeof prefix) info = info + prefix; + console.log(info + txt); + } + if (this.remoteVerbosity >= numLevel) { + // We need to avoid creating errors here, + // otherwise we enter an infinite loop. + if (this.socket.isConnected() && !this.player.placeholder) { + if (!this.remoteLogMap[txt]) { + this.remoteLogMap[txt] = true; + // There is a chance that the message is not sent, + // depending on what the state of the connection is. + // TODO: example, error on Init function, socket.io + // transport stays in state of `upgrading` and does + // not let send messages. If you manually force it + // in a debug session, they are actually sent. + this.socket.send(this.msg.create({ + target: LOG, + text: level, + data: txt, + to: 'SERVER' + })); + this.remoteLogMap[txt] = null; + } + } + } + }; + + /** + * ### NodeGameClient.info + * + * Logs an INFO message + * + * @param {string} txt The text to log + * + * @see NodeGameClient.log + */ + NGC.prototype.info = function(txt) { + this.log(txt, 'info', 'info - '); + }; + + /** + * ### NodeGameClient.warn + * + * Logs a WARNING message + * + * @param {string} txt The text to log + * + * @see NodeGameClient.log + */ + NGC.prototype.warn = function(txt) { + this.log(txt, 'warn', 'warn - '); + }; + + /** + * ### NodeGameClient.err + * + * Logs an ERROR message + * + * @param {string} txt The text to log + * + * @see NodeGameClient.log + */ + NGC.prototype.err = function(txt) { + this.log(txt, 'error', 'error - '); + }; + + /** + * ### NodeGameClient.silly + * + * Logs a SILLY message + * + * @param {string} txt The text to log + * + * @see NodeGameClient.log + */ + NGC.prototype.silly = function(txt) { + this.log(txt, 'silly', 'silly - '); + }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Setup + * Copyright(c) 2018 Stefano Balietti + * MIT Licensed + * + * `nodeGame` configuration module + * + * http://nodegame.org + */ +(function(exports, node) { + + "use strict"; + + // ## Global scope + + var J = node.JSUS; + var NGC = node.NodeGameClient; + + /** + * ### node.setup + * + * Setups the nodeGame object + * + * Configures a specific feature of nodeGame and and stores + * the settings in `node.conf`. + * + * Accepts any number of extra parameters that are passed + * to the callback function. + * + * @param {string} property The feature to configure + * + * @see node.setup.register + */ + NGC.prototype.setup = function(property) { + var res, func; + var i, len, args; + + if ('string' !== typeof property || property === '') { + throw new TypeError('node.setup: property must be a non-empty ' + + 'string. Found: ' + property); + } + + func = this._setup[property]; + if (!func) { + throw new Error('node.setup: no such property to configure: ' + + property); + } + + // Setup the property using rest of arguments. + len = arguments.length; + switch(len) { + case 1: + res = func.call(this); + break; + case 2: + res = func.call(this, arguments[1]); + break; + case 3: + res = func.call(this, arguments[1], arguments[2]); + break; + default: + len = len - 1; + args = new Array(len); + for (i = -1 ; ++i < len ; ) { + args[i] = arguments[i+1]; + } + res = func.apply(this, args); + }; + + if (property !== 'nodegame') this.conf[property] = res; + }; + + /** + * ### node.registerSetup + * + * Registers a configuration function + * + * Setup functions can be invoked remotely with in.say.SETUP messages + * and the name property stated in `msg.text`. + * + * @param {string} property The feature to configure + * @param {mixed} options The value of the option to configure + * + * @see node.setup + */ + NGC.prototype.registerSetup = function(property, func) { + if ('string' !== typeof property || property === '') { + throw new TypeError('node.setup: property must be a non-empty ' + + 'string. Found: ' + property); + } + if ('function' !== typeof func) { + throw new TypeError('node.registerSetup: func must be function. ' + + 'Found: ' + func); + } + this._setup[property] = func; + }; + + /** + * ### node.deregisterSetup + * + * Registers a configuration function + * + * @param {string} feature The name of the setup feature to deregister + * + * @see node.setup + */ + NGC.prototype.deregisterSetup = function(feature) { + if ('string' !== typeof feature) { + throw new TypeError('node.deregisterSetup: property must ' + + 'be string. Found: ' + feature); + } + if (!this._setup[feature]) { + this.warn('node.deregisterSetup: feature "' + feature + '" not ' + + 'previously registered'); + return; + } + this._setup[feature] = null; + }; + + /** + * ### node.remoteSetup + * + * Sends a setup configuration to a connected client + * + * Accepts any number of extra parameters that are sent as option values. + * + * @param {string} feature The feature to configure + * @param {string|array} to The id of the remote client to configure + * + * @return{boolean} TRUE, if configuration is successful + * + * @see node.setup + * @see JSUS.stringifyAll + */ + NGC.prototype.remoteSetup = function(feature, to) { + var msg, payload; + var i, len; + + if ('string' !== typeof feature) { + throw new TypeError('node.remoteSetup: feature must be string. ' + + 'Found: ' + feature); + } + if (!to || ('string' !== typeof to && !J.isArray(to))) { + throw new TypeError('node.remoteSetup: to must be string or ' + + 'array. Found: ' + to); + } + len = arguments.length; + if (len > 2) { + if (len === 3) payload = [arguments[2]]; + else if (len === 4) payload = [arguments[2], arguments[3]]; + else { + payload = new Array(len - 2); + for (i = 2; i < len; i++) { + payload[i - 2] = arguments[i]; + } + } + payload = J.stringifyAll(payload); + + if (!payload) { + this.err('node.remoteSetup: an error occurred while ' + + 'stringifying payload.'); + return false; + } + } + + msg = this.msg.create({ + target: this.constants.target.SETUP, + to: to, + text: feature, + data: payload + }); + + return this.socket.send(msg); + }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Alias + * Copyright(c) 2015 Stefano Balietti + * MIT Licensed + * + * `nodeGame` aliasing module + */ +(function(exports, node) { + + "use strict"; + + // ## Global scope + + var J = node.JSUS; + + var NGC = node.NodeGameClient; + + /** + * ### node.alias + * + * Creates event listeners aliases + * + * This method creates a new property to the `node.on` object named + * after the alias. The alias can be used as a shortcut to register + * to new listeners on the given events. + * + * Note: aliases cannot return values to the emit call. + * TODO: node.on aliases could do it without problem, node.once aliases + * have the problem that the return value is currently used to detect + * whether the modifier function actually executed the user callback. + * + * ```javascript + * // The node.on.data alias example with modifier function + * // only DATA msg with the right label will be fired. + * this.alias('data', ['in.say.DATA', 'in.set.DATA'], function(text, cb) { + * return function(msg) { + * if (msg.text === text) cb.call(that.game, msg); + * else return false; + * }; + * }); + * + * node.on.data('myLabel', function() { ... }; + * node.once.data('myLabel', function() { ... }; + * ``` + * + * @param {string} alias The name of alias + * @param {string|array} events The event/s under which the listeners + * will be registered + * @param {function} modifier Optional. A function that makes a closure + * around its own input parameters, and returns a function that will + * actually be invoked when the aliased event is fired. It should return + * FALSE if it does not executes the user callback. + */ + NGC.prototype.alias = function(alias, events, modifier) { + var that; + if ('string' !== typeof alias) { + throw new TypeError('node.alias: alias must be string. Found: ' + + alias); + } + if ('string' === typeof events) { + events = [events]; + } + if (!J.isArray(events)) { + throw new TypeError('node.alias: events must be array or string. ' + + 'Found: ' + events); + } + if (modifier && 'function' !== typeof modifier) { + throw new TypeError( + 'node.alias: modifier must be function or undefined. Found: ' + + modifier); + } + + that = this; + + this.on[alias] = function(func) { + var i, len, args; + + // If set, we use the callback returned by the modifier. + // Otherwise, we assume the first parameter is the callback. + if (modifier) { + args = []; + i = -1, len = arguments.length; + for ( ; ++i < len ; ) { + args[i] = arguments[i]; + } + func = modifier.apply(that.game, args); + } + + J.each(events, function(event) { + that.on(event, function() { + func.apply(that.game, arguments); + }); + }); + + }; + this.once[alias] = function(func) { + var i, len, args; + + // If set, we use the callback returned by the modifier. + // Otherwise, we assume the first parameter is the callback. + if (modifier) { + args = []; + i = -1, len = arguments.length; + for ( ; ++i < len ; ) { + args[i] = arguments[i]; + } + func = modifier.apply(that.game, args); + } + + J.each(events, function(event) { + // We redo the once method manually because otherwise + // the first call to once will remove all once listeners + // defined with this alias. Normal one calls the listener + // that wraps the modifier which may or may not execute the + // user-defined function. We introduce that if the modifier + // return false, it means the user-defined function was not + // executed and therefore it should not be removed. + function g() { + var i, len, args, toRemove; + args = []; + i = -1, len = arguments.length; + for ( ; ++i < len ; ) { + args[i] = arguments[i]; + } + toRemove = func.apply(that.game, args); + // If a modifier returns false it has not executed the + // user-defined listener, so we should not remove it. + if (!modifier || toRemove !== false) that.off(event, g); + } + that.on(event, g); + }); + }; + + // attachAlias(this, 'on', events, modifier, alias); + // attachAlias(this, 'once', events, modifier, alias); + + // this.on[alias] = function(func) { + // var i, len, args; + // args = []; + // i = -1, len = arguments.length; + // for ( ; ++i < len ; ) { + // args[i] = arguments[i]; + // } + // // If set, we use the callback returned by the modifier. + // // Otherwise, we assume the first parameter is the callback. + // if (modifier) func = modifier.apply(that.game, args); + // + // // Optimized. + // if (eventsLen < 3) { + // that.on(event[0], function() { + // func.apply(that.game, args); + // }); + // if (eventsLen === 2) { + // that.on(event[1], function() { + // func.apply(that.game, args); + // }); + // } + // } + // else { + // for ( ; ++i < len ; ) { + // that.on(event[i], function() { + // func.apply(that.game, args); + // }); + // } + // } + // }; + + // TODO: remove code duplication? + // this.once[alias] = function(func) { + // var i, len, args; + // args = []; + // i = -1, len = arguments.length; + // for ( ; ++i < len ; ) { + // args[i] = arguments[i]; + // } + // // If set, we use the callback returned by the modifier. + // // Otherwise, we assume the first parameter is the callback. + // if (modifier) func = modifier.apply(that.game, args); + // + // // Optimized. + // if (eventsLen < 3) { + // that.once(event[0], function() { + // func.apply(that.game, args); + // }); + // if (eventsLen === 2) { + // that.once(event[1], function() { + // func.apply(that.game, args); + // }); + // } + // } + // else { + // for ( ; ++i < len ; ) { + // that.once(event[i], function() { + // func.apply(that.game, args); + // }); + // } + // } + // }; + + + + }; + + // function attachAlias(that, method, events, modifier, alias) { + // var eventsLen = events.length; + // that[method][alias] = function(func) { + // var i, len, args; + // // Cloning arguments array. + // i = -1; + // len = arguments.length; + // args = new Array(len); + // for ( ; ++i < len ; ) { + // args[i] = arguments[i]; + // } + // // If set, we use the callback returned by the modifier. + // // Otherwise, we assume the first parameter is the callback. + // if (modifier) func = modifier.apply(that.game, args); + // + // // Optimized. + // if (eventsLen < 3) { + // that[method](events[0], function() { + // func.apply(that.game, arguments); + // }); + // if (eventsLen === 2) { + // that[method](events[1], function() { + // func.apply(that.game, arguments); + // }); + // } + // } + // else { + // for ( ; ++i < len ; ) { + // that[method](events[i], function() { + // func.apply(that.game, arguments); + // }); + // } + // } + // }; + // } + + + // function attachAlias(that, method, events, modifier, alias) { + // var eventsLen = events.length; + // that[method][alias] = function(func) { + // var i; + // // Cloning arguments array. + // // i = -1; + // // len = arguments.length; + // // args = new Array(len); + // // for ( ; ++i < len ; ) { + // // args[i] = arguments[i]; + // // } + // // If set, we use the callback returned by the modifier. + // // Otherwise, we assume the first parameter is the callback. + // // if (modifier) func = modifier.apply(that.game, args); + // + // // Optimized. + // + // that[method](events[0], function() { + // if (modifier) { + // func = modifier.apply(that.game, arguments); + // if (!func) return; + // } + // func.apply(that.game, arguments); + // }); + // if (eventsLen === 2) { + // that[method](events[1], function() { + // if (modifier) { + // func = modifier.apply(that.game, arguments); + // if (!func) return; + // } + // func.apply(that.game, arguments); + // }); + // } + // else { + // i = 0; + // for ( ; ++i < eventsLen ; ) { + // that[method](events[i], function() { + // if (modifier) { + // func = modifier.apply(that.game, arguments); + // if (!func) return; + // } + // func.apply(that.game, arguments); + // }); + // } + // } + // }; + // } +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Connect + * Copyright(c) 2016 Stefano Balietti + * MIT Licensed + * + * `nodeGame` connect module + */ +(function(exports, parent) { + + "use strict"; + + var NGC = parent.NodeGameClient; + + /** + * ### node.connect + * + * Establishes a connection with a nodeGame server + * + * Depending on the type of socket used (Direct or IO), the + * channel parameter might be optional. + * + * If node is executed in the browser additional checks are performed: + * + * 1. If channel does not begin with `http://` or `https://, + * then `window.location.origin` will be added in front of + * channel to avoid cross-domain errors (as of Socket.io >= 1). + * + * 2. If no socketOptions.query parameter is specified any query + * parameters found in `location.search(1)` will be passed. + * + * @param {string} channel Optional. The channel to connect to + * @param {object} socketOptions Optional. A configuration object for + * the socket connect method. If channel is omitted, then socketOptions + * is the first parameter. + * + * @emit SOCKET_CONNECT + * @emit PLAYER_CREATED + * @emit NODEGAME_READY + */ + NGC.prototype.connect = function() { + var channel, socketOptions; + if (arguments.length >= 2) { + channel = arguments[0]; + socketOptions = arguments[1]; + } + else if (arguments.length === 1) { + if ('string' === typeof arguments[0]) channel = arguments[0]; + else socketOptions = arguments[0]; + } + // Browser adjustements. + if ('undefined' !== typeof window) { + // If no channel is defined use the pathname, and assume + // that the name of the game is also the name of the endpoint. + if ('undefined' === typeof channel) { + if (window.location && window.location.pathname) { + channel = window.location.pathname; + // Making sure it is consistent with what we expect. + if (channel.charAt(0) !== '/') channel = '/' + channel; + if (channel.charAt(channel.length-1) === '/') { + channel = channel.substring(0, channel.length-1); + } + } + } + // Make full path otherwise socket.io will complain. + if (channel && + (channel.substr(0,8) !== 'https://' && + channel.substr(0,7) !== 'http://')) { + + if (window.location && window.location.origin) { + channel = window.location.origin + channel; + } + } + // Pass along any query options. (?clientType=...). + if (!socketOptions || (socketOptions && !socketOptions.query)) { + if (('undefined' !== typeof location) && location.search) { + socketOptions = socketOptions || {}; + socketOptions.query = location.search.substr(1); + } + } + } + this.socket.connect(channel, socketOptions); + }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Player + * Copyright(c) 2017 Stefano Balietti + * MIT Licensed + * + * Player related functions + */ +(function(exports, parent) { + + "use strict"; + + var NGC = parent.NodeGameClient, + Player = parent.Player, + constants = parent.constants; + + /** + * ### NodeGameClient.createPlayer + * + * Creates player object and places it in node.player + * + * @param {object} player A player object with a valid id property + * + * @return {object} The player object + * + * @see node.setup.player + * @emit PLAYER_CREATED + */ + NGC.prototype.createPlayer = function(player) { + if (this.player && + this.player.stateLevel > constants.stateLevels.STARTING && + this.player.stateLevel !== constants.stateLevels.GAMEOVER) { + throw new Error('node.createPlayer: cannot create player ' + + 'while game is running.'); + } + if (this.game.pl.exist(player.id)) { + throw new Error('node.createPlayer: id already found in ' + + 'playerList: ' + player.id); + } + // Cast to player (will perform consistency checks) + player = new Player(player); + player.stateLevel = this.player.stateLevel; + player.stageLevel = this.player.stageLevel; + + this.player = player; + // Slice because here it is SP/123, and on server it is /123. + this.player.strippedSid = this.player.sid.slice(2); + + this.emit('PLAYER_CREATED', this.player); + + return this.player; + }; + + /** + * ### NodeGameClient.setLanguage + * + * Sets the language for the client + * + * @param {object|string} lang Language information. If string, it must + * be the full name, and the the first 2 letters lower-cased are used + * as shortName. If object it must have the following format: + * ``{ + * name: 'English', + * shortName: 'en', + * nativeName: 'English', + * path: 'en/' // Optional, default equal to shortName + '/'. + * }`` + * + * @param {boolean} updateUriPrefix Optional. If TRUE, the window uri + * prefix isset to the value of lang.path. node.window must be defined, + * otherwise a warning is shown. Default, FALSE. + * @param {boolean} sayIt Optional. If TRUE, a LANG message is sent to + * the server to notify the selection. Default: FALSE. + * + * @return {object} The language object + * + * @see node.setup.lang + * @see GameWindow.setUriPrefix + * + * @emit LANGUAGE_SET + */ + NGC.prototype.setLanguage = function(lang, updateUriPrefix, sayIt) { + var language; + language = 'string' === typeof lang ? makeLanguageObj(lang) : lang; + + if (!language || 'object' !== typeof language) { + throw new TypeError('node.setLanguage: language must be object ' + + 'or string. Found: ' + lang); + } + if ('string' !== typeof language.shortName) { + throw new TypeError( + 'node.setLanguage: language.shortName must be string. Found: ' + + language.shortName); + } + this.player.lang = language; + if (!this.player.lang.path) { + this.player.lang.path = language.shortName + '/'; + } + + // Updates the URI prefix. + if (updateUriPrefix) { + if ('undefined' !== typeof this.window) { + this.window.setUriPrefix(this.player.lang.path); + } + else { + node.warn('node.setLanguage: updateUriPrefix is true, ' + + 'but window not found. Are you in a browser?'); + } + } + + // Send a message to notify server. + if (sayIt) { + node.socket.send(node.msg.create({ + target: 'LANG', + data: this.player.lang + })); + } + + this.emit('LANGUAGE_SET'); + + return this.player.lang; + }; + + // ## Helper functions. + + /** + * ### makeLanguageObj + * + * From a language string returns a fully formatted obj + * + * @param {string} langStr The language string. + * + * @return {object} The language object + */ + function makeLanguageObj(langStr) { + var shortName; + shortName = langStr.toLowerCase().substr(0,2); + return { + name: langStr, + shortName: shortName, + nativeName: langStr, + path: shortName + '/' + }; + } + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # Events + * Copyright(c) 2015 Stefano Balietti + * MIT Licensed + * + * `nodeGame` events handling + */ + +(function(exports, parent) { + + "use strict"; + + var NGC = parent.NodeGameClient; + + var GameStage = parent.GameStage; + + var STAGE_INIT = parent.constants.stateLevels.STAGE_INIT; + var STAGE_EXIT = parent.constants.stateLevels.STAGE_EXIT; + + /** + * ### NodeGameClient.getCurrentEventEmitter + * + * Returns the currently active event emitter + * + * The following event emitters are active: + * + * - NodeGame (ng): before a game is created or started. + * Events registered here never deleted. + * + * - Game (game): during the initialization of a game + * Events registered here are deleted when a new game + * is created. + * + * - Stage (stage): during the initialization of a stage. + * Events registered here are deleted when entering a + * new stage. + * + * - Step (step): during the initialization of a step. + * Events registered here are deleted when entering a + * new step. + * + * @return {EventEmitter} The current event emitter + * + * @see EventEmitter + * @see EventEmitterManager + */ + NGC.prototype.getCurrentEventEmitter = function() { + var gameStage, stateL; + + // NodeGame default listeners + if (!this.game) return this.events.ee.ng; + gameStage = this.game.getCurrentGameStage(); + if (!gameStage) return this.events.ee.ng; + + // Game listeners. + if ((GameStage.compare(gameStage, new GameStage()) === 0 )) { + return this.events.ee.game; + } + + // Stage listeners. + stateL = this.game.getStateLevel(); + if (stateL === STAGE_INIT || stateL === STAGE_EXIT) { + return this.events.ee.stage; + } + + // Step listeners. + return this.events.ee.step; + }; + +})( + 'undefined' != typeof node ? node : module.exports, + 'undefined' != typeof node ? node : module.parent.exports +); + +/** + * # SAY, SET, GET, DONE + * + * Implementation of node.[say|set|get|done]. + * + * Copyright(c) 2020 Stefano Balietti + * MIT Licensed + */ +(function(exports, parent) { + + "use strict"; + + var NGC = parent.NodeGameClient; + var J = parent.JSUS; + + var stageLevels = parent.constants.stageLevels; + var GETTING_DONE = stageLevels.GETTING_DONE; + + /** + * ### NodeGameClient.say + * + * Sends a DATA message to a specified recipient + * + * @param {string} text The label associated to the msg + * @param {string|array} Optional. to The recipient/s of the msg. + * Default: 'SERVER' + * @param {mixed} payload Optional. Addional data to send along + * + * @return {boolean} TRUE, if SAY message is sent + */ + NGC.prototype.say = function(label, to, payload) { + var msg; + if ('string' !== typeof label || label === '') { + throw new TypeError('node.say: label must be string. Found: ' + + label); + } + if (to && 'string' !== typeof to && (!J.isArray(to) || !to.length)) { + throw new TypeError('node.say: to must be a non-empty array, ' + + 'string or undefined. Found: ' + to); + } + msg = this.msg.create({ + target: this.constants.target.DATA, + to: to, + text: label, + data: payload + }); + return this.socket.send(msg); + }; + + /** + * ### NodeGameClient.set + * + * Stores an object in the server's memory + * + * @param {object|string} o The value to set + * @param {string} to Optional. The recipient. Default `SERVER` + * @param {string} text Optional. The text property of the message. + * If set, it allows one to define on.data listeners on receiver. + * Default: undefined + * + * @return {boolean} TRUE, if SET message is sent + */ + NGC.prototype.set = function(o, to, text) { + var msg, tmp; + if ('string' === typeof o) { + tmp = o, o = {}, o[tmp] = true; + } + else if ('object' !== typeof o) { + throw new TypeError('node.set: o must be object or string. ' + + 'Found: ' + o); + } + msg = this.msg.create({ + action: this.constants.action.SET, + target: this.constants.target.DATA, + to: to || 'SERVER', + reliable: 1, + data: o + }); + if (text) msg.text = text; + return this.socket.send(msg); + }; + + /** + * ### NodeGameClient.get + * + * Sends a GET message to a recipient and listen to the reply + * + * The receiver of a GET message must be implement an *internal* listener + * of the type "get.