File:Learning.js

/**
 * @module Learning
 * @namespace springroll.pbskids
 * @requires Core
 */
(function($, undefined)
{
	// Core SpringRoll classes
	var Application = include('springroll.Application');
	var Debug = include('springroll.Debug', false);
	var SavedData = include('springroll.SavedData');
	var EventDispatcher = include('springroll.EventDispatcher');

	// Internal classes
	var EventCatalog = include('springroll.pbskids.EventCatalog');
	var EventError = include('springroll.pbskids.EventError');
	var EventSignature = include('springroll.pbskids.EventSignature');
	var EventUtils = include('springroll.pbskids.EventUtils');
	var LearningError = include('springroll.pbskids.LearningError');
	var ValidationError = include('springroll.pbskids.ValidationError');

	/**
	 * The base game class
	 * @class Learning
	 * @extends springroll.EventDispatcher
	 * @constructor
	 * @param {springroll.Application} app The application reference
	 * @param {boolean} [showTray=false] Show the documentation at init or false (dev build only!)
	 */
	var Learning = function(app, showTray)
	{
		EventDispatcher.call(this);

		/**
		 * Create a new instance of the event catalog
		 * @property {springroll.EventCatalog} catalog
		 */
		this.catalog = new EventCatalog();

		if (DEBUG)
		{
			if ($ === undefined)
			{
				this._handleError('jQuery is required for debug mode');
				return;
			}

			/**
			 * The documentation dom element, development build only!
			 * @property {Element} _tray
			 * @private
			 */
			this._tray = $('<div class="learning-tray">' +
				'<h2>Learning API <span class="learning-version"></span></h2>' +
				'</div>');

			/**
			 * The toggle handle dom element, development build only!
			 * @property {Element} _handle
			 * @private
			 */
			this._handle = $('<button class="learning-handle"></button>');

			// Match the last position of the PT tray.
			// ie Start with the tray open ('learning-tray-show') when reloading
			// or returning to the game.
			var defaultTrayPosition = SavedData.read('learning-tray-show') ?
				'learning-tray-show' :
				'learning-tray-hide';

			/**
			 * The body dom element, development build only!
			 * @property {Element} _body
			 * @private
			 */
			this._body = $("body").append(this._tray, this._handle)
				.addClass(defaultTrayPosition);

			this._handle.click(this.toggleDocs.bind(this));

			this.showTray = !!showTray;
		}

		/**
		 * The collection of timers
		 * @property {object} _timers
		 * @private
		 */
		this._timers = {};

		//Add the spec, can be added later
		this.spec = null;

		/**
		 * The reference to the application
		 * @property {springroll.Application} _app
		 * @private
		 */
		this._app = app;

		/**
		 * The saved feedback or instructions
		 * @property {Object} _feedback
		 * @private
		 */
		this._feedback = null;

		/**
		 * The saved data for movie events
		 * @property {Object} _movie
		 * @private
		 */
		this._movie = null;

		/**
		 * The collection of api methods called
		 * @property {array} _history
		 * @private
		 */
		this._history = [];

		/**
		 * The current level number if support, null if unsupported
		 * @property {int} _round
		 * @private
		 * @default null
		 */
		this._level = null;

		/**
		 * The current round number if support, null if unsupported
		 * @property {int} _round
		 * @private
		 * @default null
		 */
		this._round = null;

		/**
		 * Keep track of the round a feedback event was started on
		 * the ending event should dispatch the same round.
		 * @property {int} _feedbackStartRound
		 * @private
		 * @default null
		 */
		this._feedbackStartRound = null;

		//Add event to handle the internal timers
		updateTimers = updateTimers.bind(this);
		app.on('update', updateTimers);

		//Add a listeners for called
		this.on(CALLED, this._onCalled.bind(this));
	};

	/**
	 * If the Learning should throw errors
	 * @property {Boolean} throwErrors
	 * @static
	 */
	Learning.throwErrors = false;

	//Reference to the prototype
	var s = EventDispatcher.prototype;
	var p = EventDispatcher.extend(Learning);

	/**
	 * An event is tracked
	 * @event learningEvent
	 * @param {object} data The event data
	 * @param {string} data.game_id The unique game id
	 * @param {string} data.event_id The unique event id
	 * @param {object} data.event_data The data attached to event
	 * @param {int} data.event_data.event_code The code of the event
	 */
	var EVENT = 'learningEvent';

	/**
	 * An api method was called, this happens before any validation
	 * @event called
	 * @param {string} api The name of the api method called
	 */
	var CALLED = 'called';

	/**
	 * Handle errors
	 * @method _handleError
	 * @private
	 * @param {Error} error The error to handle
	 * @return {[type]}       [description]
	 */
	p._handleError = function(error)
	{
		try
		{
			if (typeof error === "string")
			{
				error = new LearningError(error);
			}
			throw error;
		}
		catch (e)
		{
			if (DEBUG)
			{
				if (e instanceof ValidationError)
				{
					this._showError(e.message, e.api, e.property);
				}
				else if (e instanceof EventError)
				{
					this._showError(e.message, e.api);
				}
				if (Debug)
				{
					Debug.error(error);
				}
			}
			if (Learning.throwErrors)
			{
				throw e;
			}
		}
	};

	/**
	 * The map of API event name overrides
	 * @method addMap
	 * @param {object} eventDictionary The collection of game-specific APIs, this is a map
	 *       of the eventCode to the name of the API method
	 */
	p.addMap = function(eventDictionary)
	{
		if (eventDictionary)
		{
			try
			{
				this.catalog.add(eventDictionary);
			}
			catch (e)
			{
				this._handleError(e);
			}
		}
	};

	/**
	 * The tracking specification
	 * @property {object} spec
	 * @property {string} spec.gameId
	 * @property {int} spec.version
	 * @property {array} spec.events
	 */
	Object.defineProperty(p, "spec",
	{
		get: function()
		{
			return this._spec;
		},
		set: function(spec)
		{
			this._spec = spec;

			if (spec)
			{
				var api, args, eventData, eventCode;
				for (eventCode in spec.events)
				{
					api = this.catalog.events[eventCode];

					if (!api)
					{
						api = 'event' + eventCode;
						this.catalog.events[eventCode] = api;
					}

					eventData = spec.events[eventCode];

					//Create the dynamic API method based on the
					//arguments found in the spec event data
					if (this[api] === undefined)
					{
						this[api] = this._specTrack.bind(this, api);
						args = eventData.args;
					}
					//Allow for a staticly defined override
					else
					{
						args = EventCatalog.args[eventCode];
					}

					//Create a new signature for the api call
					var signature = new EventSignature(
						eventCode,
						api,
						args,
						eventData.args,
						eventData.info
					);

					this[api].signature = signature;

					if (DEBUG)
					{
						this._tray.append(signature.docs());
					}
				}
				//Populate the tray with some information
				if (DEBUG)
				{
					$(".learning-version").text(spec.version);
					$(".learning-api").click(this._toggleRowCollapse.bind(this));
				}
			}
		}
	});

	if (DEBUG)
	{
		/**
		 * When clicking on a method name
		 * @method _toggleRowCollapse
		 * @private
		 * @param {event} e The click event
		 */
		p._toggleRowCollapse = function(e)
		{
			$(e.currentTarget).parent().toggleClass('collapsed');
		};

		/**
		 * Toogle the display of the documentation
		 * @method toggleDocs
		 */
		p.toggleDocs = function()
		{
			var show = !this._body.hasClass('learning-tray-show');
			this._body.removeClass('learning-tray-show learning-tray-hide')
				.addClass(show ? 'learning-tray-show' : 'learning-tray-hide');

			//remember the position of the tray for this session
			SavedData.write('learning-tray-show', show);

			this._app.triggerResize();
		};

		/**
		 * Show the documentation panel, development build only!
		 * @property {boolean} showTray
		 */
		Object.defineProperty(p, 'showTray',
		{
			set: function(show)
			{
				this._tray.hide();
				if (show)
				{
					this._tray.show();
				}
			}
		});
	}

	if (RELEASE)
	{
		//Set-up public methods for release build
		//so that the API stays consistent
		p.toggleDocs = function()
		{
			this._handleError("toggleDocs only available in dev build");
		};

		Object.defineProperty(p, 'showTray',
		{
			set: function(show)
			{
				this._handleError("showTray setter only available in dev build");
			}
		});
	}

	/**
	 * Convenience function for measuring the duration which is common
	 * for many events. These timers respect the application being paused
	 * and should be use instead of implementing Date.now() or some other
	 * Date-based method.
	 * @method startTimer
	 * @param {string} alias A unique alias for this timer
	 */
	p.startTimer = function(alias)
	{
		if (this._timers[alias] !== undefined)
		{
			this._handleError("Timer exists matching '" + alias + "', call stopTimer first");
			return;
		}
		this._timers[alias] = 0;
	};

	/**
	 * Check the current progress of a timer, this will not destory the timer
	 * @method pollTimer
	 * @param {string} alias The unique alias for this timer
	 * @return {int} The timer in milliseconds
	 */
	p.pollTimer = function(alias)
	{
		if (this._timers[alias] === undefined)
		{
			this._handleError("Timer doesn't exist matching '" + alias + "'");
			return;
		}
		return this._timers[alias] | 0;
	};

	/**
	 * Get the amount of time since the start of the game
	 * @method gameTime
	 * @return {int} The time since the beginning of the game in milliseconds
	 */
	p.gameTime = function()
	{
		return this.pollTimer('_game');
	};

	/**
	 * Stop a timer and get the final duration to send with an event. This
	 * will clean-up and discard the timer and it can't be used again.
	 * @method stopTimer
	 * @param {string} alias The unique alias for this timer
	 * @return {int} The timer in milliseconds
	 */
	p.stopTimer = function(alias)
	{
		var duration = this.pollTimer(alias);
		this.removeTimer(alias);
		return duration;
	};

	/**
	 * This will clean-up and discard the timer and it can't be used again.
	 * @method removeTimer
	 * @param {string} alias The unique alias for this timer
	 */
	p.removeTimer = function(alias)
	{
		if (this._timers[alias] !== undefined)
		{
			delete this._timers[alias];
		}
	};

	/**
	 * Handle the frame update
	 * @method updateTimers
	 * @private
	 * @param {int} elapsed The number of milliseconds since the last update
	 */
	var updateTimers = function(elapsed)
	{
		for (var alias in this._timers)
		{
			this._timers[alias] += elapsed;
		}
	};

	/**
	 * Override for start game event
	 * @method startGame
	 */
	p.startGame = function()
	{
		var sign = this.startGame.signature;

		//make sure signature exists
		if (!sign)
		{
			this._handleError("startGame: signature is undefined");
			return;
		}

		//Initialize the round
		if (sign.hasProperty('round', true))
		{
			this._round = 0;
		}

		//Initialize the level
		if (sign.hasProperty('level', true))
		{
			this._level = 0;
		}

		//Reset the history on start game
		this._history.length = 0;
		this._history.push('startGame');

		this.startTimer('_game');
		this._track('startGame',
		{
			version: this._spec.version
		});
	};

	/**
	 * When a user clicks or taps on the play button to start the game.
	 * @method clickPlay
	 * @param {Object} coordinates The coordinates object
	 * @param {int} coordinates.x The x position clicked or tapped
	 * @param {int} coordinates.y The y position clicked or tapped
	 * @param {int} coordinates.stage_width The stage width 
	 * @param {int} coordinates.stage_height The stage height
	 */
	p.clickPlay = function(coordinates)
	{
		if (!this.clickPlay.signature)
		{
			this._handleError("clickPlay: signature is undefined");
			return;
		}
		this._track('clickPlay',
		{
			coordinates: coordinates
		});
	};

	/**
	 * Override for the end game event
	 * @method endGame
	 * @param {string} [exitType] The exit type for certain games
	 */
	p.endGame = function(exitType)
	{
		var sessionDuration = this.gameTime();
		var signature = this.endGame.signature;

		if (signature.hasProperty('exit_type', true))
		{
			this._track('endGame',
			{
				session_duration: sessionDuration,
				exit_type: exitType || ''
			});
		}
		else
		{
			this._track('endGame',
			{
				session_duration: sessionDuration
			});
		}

		//Reset the history on start game
		this._history.length = 0;
	};

	/**
	 * Basic method for starting a feedback or instruction
	 * @method _startFeedback
	 * @private
	 * @param {string} api     The event method to call
	 * @param {string} description   Description of the instruction
	 * @param {string} identifier    A unique identifier
	 * @param {string} mediaType     Either audio animation or other
	 * @param {int} totalDuration The estimated time of instruction in milliseconds
	 */
	p._startFeedback = function(api, description, identifier, mediaType, totalDuration)
	{
		if (this._feedback)
		{
			this._handleError("Feedback or instruction already started, stop it first");
			return;
		}
		var feedback = {
			media_type: mediaType,
			description: description,
			identifier: identifier,
			total_duration: totalDuration
		};
		this._feedbackStartRound = this._round;
		this._track(api, feedback);
		this.startTimer('_feedback');
		this._feedback = feedback;
	};

	/**
	 * Basic method for starting a feedback or instruction
	 * @method _startFeedback
	 * @private
	 * @param {string} api The event method to call
	 */
	p._endFeedback = function(api)
	{
		var feedback = this._feedback;
		if (!feedback)
		{
			this._handleError("Feedback or instruction not found, start it first");
			return;
		}
		delete feedback.total_duration;
		feedback.duration = this.stopTimer('_feedback');
		this._feedback = null;
		this._track(api, feedback, this._feedbackStartRound);
		this._feedbackStartRound = null;
	};

	/**
	 * Start the system initiated instruction
	 * @method startInstruction
	 * @param {string} description The text description of the instruction
	 * @param {string} identifier A unique identifier for this peice of instruction
	 * @param {string} mediaType The type of media, audio animation or other
	 * @param {int} total_duration The estimated duration of the media in milliseconds
	 */
	p.startInstruction = function(description, identifier, mediaType, totalDuration)
	{
		this._startFeedback('startInstruction', description, identifier, mediaType, totalDuration);
	};

	/**
	 * End the system initiated instruction
	 * @method endInstruction
	 */
	p.endInstruction = function()
	{
		if (!this.requires || !this.requires('startInstruction')) return;
		this._endFeedback('endInstruction');
	};

	/**
	 * Start the incorrect feedback
	 * @method startIncorrectFeedback
	 * @param {string} description The text description of the instruction
	 * @param {string} identifier A unique identifier for this peice of instruction
	 * @param {string} mediaType The type of media, audio animation or other
	 * @param {int} total_duration The estimated duration of the media in milliseconds
	 */
	p.startIncorrectFeedback = function(description, identifier, mediaType, totalDuration)
	{
		this._startFeedback('startIncorrectFeedback', description, identifier, mediaType, totalDuration);
	};

	/**
	 * End the incorrect feedback
	 * @method endIncorrectFeedback
	 */
	p.endIncorrectFeedback = function()
	{
		if (!this.requires || !this.requires('startIncorrectFeedback')) return;
		this._endFeedback('endIncorrectFeedback');
	};

	/**
	 * Start the correct feedback event
	 * @method startCorrectFeedback
	 * @param {string} description The text description of the instruction
	 * @param {string} identifier A unique identifier for this peice of instruction
	 * @param {string} mediaType The type of media, audio animation or other
	 * @param {int} total_duration The estimated duration of the media in milliseconds
	 */
	p.startCorrectFeedback = function(description, identifier, mediaType, totalDuration)
	{
		this._startFeedback(
			'startCorrectFeedback',
			description,
			identifier,
			mediaType,
			totalDuration
		);
	};

	/**
	 * End the correct feedback event
	 * @method endCorrectFeedback
	 */
	p.endCorrectFeedback = function()
	{
		if (!this.requires || !this.requires('startCorrectFeedback')) return;
		this._endFeedback('endCorrectFeedback');
	};

	/**
	 * The movie started
	 * @method startMovie
	 * @param {string} movieId The identifier for the movie that's playing
	 * @param {int} duration  The duration of the media playback in milliseconds
	 * @param {string} description The text or description of the instruction
	 */
	p.startMovie = function(movieId, duration, description)
	{
		if (this._movie)
		{
			this._handleError("Movie is already started called skipMovie or endMovie first");
			return;
		}
		this._movie = {
			movie_id: movieId,
			duration: duration,
			description: description
		};
		this.startTimer('_movie');
		this._track('startMovie', this._movie);
	};

	/**
	 * The user decided to skip the movie playback by clicking a skip button
	 * @method skipMovie
	 */
	p.skipMovie = function()
	{
		if (!this.requires || !this.requires('startMovie')) return;

		var movie = this._movie;
		if (!movie)
		{
			this._handleError("No movie started, call startMovie first");
			return;
		}
		movie.time_played = this.stopTimer('_movie');
		this._movie = null;
		this._track('skipMovie', movie);
	};

	/**
	 * The movie ended
	 * @method endMovie
	 */
	p.endMovie = function()
	{
		if (!this.requires || !this.requires('startMovie')) return;

		var data = this._movie;
		if (!data)
		{
			this._handleError("No movie started, call startMovie first");
			return;
		}
		this.removeTimer('_movie');
		var movie = this._movie;
		this._movie = null;
		this._track('endMovie', movie);
	};

	/**
	 * Handler when an api is called
	 * @method _onCalled
	 * @private
	 * @param {string} api The name of the API method called
	 */
	p._onCalled = function(api)
	{
		if (api === 'startRound' && this._round !== null)
		{
			this._round++;
		}
		else if (api === 'startLevel' && this._level !== null)
		{
			this._level++;
		}
	};

	/**
	 * Generic method to track an event based on the spec, the arguments
	 * mirror the arguments in the event spec.
	 * @method _specTrack
	 * @private
	 * @param {string} api The name of the api
	 * @param {*} [...extraArgs] The Additional arguments
	 */
	p._specTrack = function(api)
	{
		var signature = this[api].signature,
			data = null;

		try
		{
			data = EventUtils.argsMap(
				signature.args,
				Array.prototype.slice.call(arguments, 1)
			);
		}
		catch (error)
		{
			if (error instanceof EventError)
			{
				error.api = api;
				error.eventCode = this.catalog.lookup(api);
				error.signature = signature;
			}
			this._handleError(error);
			return;
		}

		//Now we have a formatted data object, pass to the track method
		this._track(api, data);
	};

	/**
	 * Generic method to track an event based on the spec, the arguments
	 * mirror the arguments in the event spec.
	 * @method _track
	 * @private
	 * @param {string} api The name of the api
	 * @param {object} [input] The collection of argument values
	 * @param {int} [round] The explicit round to add the track event for
	 */
	p._track = function(api, input, round)
	{
		if (!this.requires || !this.requires('startGame')) return;

		var eventCode = this.catalog.lookup(api),
			eventData = this._spec.events[eventCode],
			signature = this[api].signature,
			data = null;

		//Check that the event code is valid on this spec
		if (eventData === undefined)
		{
			this._handleError(new EventError("Supplied event code is invalid", eventCode, api));
			return;
		}

		try
		{
			//Validate the specification arguments against the input
			data = EventUtils.validate(
				signature.eventArgs,
				input
			);
		}
		catch (error)
		{
			if (error instanceof EventError)
			{
				error.api = api;
				error.eventCode = eventCode;
				error.signature = signature;
			}
			this._handleError(error);
			return;
		}

		//Trigger the called event, validation checked out
		this.trigger(CALLED, api);

		//If we're using the concept of levels, add it
		if (this._level !== null)
		{
			data.level = this._level;
		}

		//If we're using the concept of rounds, add it
		if (round !== undefined && round !== null)
		{
			data.round = round;
		}
		else if (this._round !== null)
		{
			data.round = this._round;
		}

		//Get the current game time since the start
		//this gets applyed to all events being sent
		data.game_time = this.gameTime();

		//Add the event code to the data
		data.event_code = parseInt(eventCode);

		if (DEBUG)
		{
			$("#learning-api-" + api).addClass('success');
		}

		//Key track of the tracking history
		//so we can do a history check
		//using requires
		this._history.push(api);

		//Trigger an event where the event is the API
		//and the parameter is the event data
		this.trigger(api, data);

		//Dispatch the tracking event here
		this.trigger(
			EVENT,
			{
				game_id: this._spec.gameId,
				event_id: eventData.id,
				event_data: data
			}
		);
	};

	if (DEBUG)
	{
		/**
		 * Display an error in the tray
		 * @method _showError
		 * @private
		 * @param {string} message The message to log
		 * @param {string} api      The name of the api
		 * @param {string} [property] Optional property
		 */
		p._showError = function(message, api, property)
		{
			var container = $("#learning-api-" + api)
				.addClass('error')
				.removeClass('collapsed');

			message = '<span class="learning-api-alert">' + message + '</span>';

			// Add the erroring to the property specifically
			if (property)
			{
				container.find(".arg-" + property)
					.addClass('error')
					.prepend(message);
			}
			// Add the error message to the container
			else
			{
				container.find('.learning-api').after(message);
			}
		};
	}

	/**
	 * Require that an api has been called
	 * @method requires
	 * @param {String} api The names of the method or API call
	 * @return {Boolean} If the api was called before
	 */
	p.requires = function(api)
	{
		if (this._history.indexOf(api) === -1)
		{
			this._handleError("Learning API '" + api + "' needs to be called first");
			return false;
		}
		return true;
	};

	/**
	 * Don't use after this
	 * @method destroy
	 */
	p.destroy = function()
	{
		if (this._app)
		{
			this._app.off('update', updateTimers);
		}

		if (this.catalog)
		{
			this.catalog.destroy();
			this.catalog = null;
		}

		this.off(CALLED);

		if (DEBUG)
		{
			this._body.removeClass('learning-tray-show learning-tray-hide');
			this._handle.remove();
			this._tray.remove();
			this._handle = null;
			this._tray = null;
			this._body = null;
		}
		this._app = null;
		this._timers = null;
		this._spec = null;
		this._history = null;
		this._movie = null;
		this._feedback = null;

		s.destroy.call(this);
	};

	//Assign to namespace
	namespace('springroll.pbskids').Learning = Learning;

}(window.jQuery));