e-ucm/js-tracker

View on GitHub
src/js-tracker.js

Summary

Maintainability
F
2 wks
Test Coverage
/*
 * Copyright 2017 e-UCM, Universidad Complutense de Madrid
 *
 * Licensed under the Apache License, Version 2.0 (the 'License');
 * you may not use this file except in compliance with the License.
 * This project has received funding from the European Union’s Horizon
 * 2020 research and innovation programme under grant agreement No 644187.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an 'AS IS' BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
'use strict';

var request = require('request');
var moment = require('moment');

function TrackerAsset() {

    this.settings = {
        host: 'https://rage.e-ucm.es/',
        port: 443,
        secure: true,
        trackingCode: '',
        userToken: '',
        max_flush: 4,
        batch_size: 10,
        backupStorage: false,
        debug: true,
        force_actor: true
    };

    this.collector = 'proxy/gleaner/collector/';
    this.backup_file = '';

    this.url = '';
    this.logged_token = '';
    this.auth = '';
    this.playerId = '';
    this.objectId = '';

    this.started = false;
    this.connected = false;
    this.active = false;

    this.session = 0;
    this.actor = '{}';
    this.extensions = {};

    this.queue = [];
    this.tracesPending = [];
    this.tracesUnlogged = [];

    this.Accessible = new Accessible(this);
    this.Alternative = new Alternative(this);
    this.Completable = new Completable(this);
    this.GameObject = new GameObject(this);

    this.generateURL = function() {
        var splitted = this.settings.host.split ('/');
        var host_splitted = '';
        var secure = false;

        if (splitted.length > 1 && splitted[0].startsWith('http')) {
            host_splitted = splitted [2].split (':');
            secure = splitted[0] === 'https:';
        } else {
            host_splitted = splitted [0].split (':');
            secure = this.settings.secure;
        }

        var domain = host_splitted [0];

        this.port = 80;
        if (host_splitted[1] && host_splitted[1].length > 1) {
            this.port = parseInt(host_splitted[1]);
        } else {
            this.port = secure ? 443 : 80;
        }

        this.url = (secure ? 'https://' : 'http://') + domain + ':' + this.port + '/api/';

        if (this.settings.debug) {
            console.log('Final Tracker URL is: ' + this.url);
        }
    };

    this.Login = function(username, password, callback) {
        this.generateURL();

        var tracker = this;
        this.HttpRequest(this.url + 'login', 'post', {'Content-Type': 'application/json' }, JSON.stringify({username: username, password: password}),
         function (data) {
            tracker.settings.userToken = 'Bearer ' + data.user.token;
            if (tracker.settings.debug) {
                console.info('AuthToken: ' + data.user.token);
            }
            callback(data, null);

        },
         function (data) {
            if (tracker.settings.debug && data.responseJSON) {
                console.log(data.responseJSON);
            }
            callback(data, true);
        });
    };

    this.LoginBeaconing = function(accessToken, callback) {
        this.generateURL();

        var tracker = this;
        this.HttpRequest(this.url + 'login/beaconing', 'post', {'Content-Type': 'application/json' }, JSON.stringify({accessToken: accessToken}),
         function (data) {
            tracker.settings.userToken = 'Bearer ' + data.user.token;
            if (tracker.settings.debug) {
                console.info('AuthToken: ' + data.user.token);
            }
            callback(data, null);

        },
         function (data) {
            if (tracker.settings.debug && data.responseJSON) {
                console.log(data.responseJSON);
            }
            callback(data, true);
        });
    };

    this.Start = function(callback) {
        this.started = true;

        if (this.settings.backupStorage) {
            this.backup_file = 'backup_' + Math.random().toString(36).slice(2);
            if (this.settings.debug) {
                console.log('Backup file is: ' + this.backup_file);
            }
        }

        this.Connect(callback);
    };

    this.Stop = function() {
        this.active = false;
        this.connected = false;
        this.started = false;
        this.actor = null;
        this.queue = [];
        this.userToken = null;
        this.playerId = null;
        this.tracesPending = [];
        this.tracesUnlogged = [];
        this.extensions = {};
    };

    this.Connect = function(callback) {
        this.generateURL();

        var tracker = this;

        var headers = {
            'Content-Type': 'application/json'
        };

        var body = '';

        if (this.settings.userToken) {
            if (this.settings.userToken.indexOf('Bearer') !== -1) {
                headers.Authorization = this.settings.userToken;
            } else {
                body = JSON.stringify({anonymous: this.settings.userToken});
            }
        }else if (this.playerId) {
            body = JSON.stringify({anonymous: this.playerId});
        }

        this.HttpRequest(this.url + this.collector + 'start/' + this.settings.trackingCode, 'post', headers, body,
         function (data) {
            if (tracker.settings.debug) {
                console.info(data);
            }

            tracker.auth = data.authToken;
            tracker.actor = data.actor;
            tracker.playerId = data.playerId;
            tracker.objectId = data.objectId;
            tracker.session = data.session;

            if (headers.Authorization) {
                tracker.userToken = headers.Authorization;
            }else {
                tracker.userToken = data.playerId;
            }

            tracker.connected = true;
            if (!(tracker.actor === null || tracker.actor === '{}')) {
                tracker.active = true;
            }

            if (tracker.settings.debug) {
                console.log('Tracker Started: ' + tracker.userToken);
            }

            callback(data, null);
        },
         function (data) {
            if (tracker.settings.debug) {
                if (data.responseJSON) {
                    console.log(data.responseJSON);
                }else {
                    console.log('Can\'t connect.');
                }
            }

            callback(data, true);
        });
    };

    this.ActionTrace = function(verb,type,id) {
        var t = new TrackerEvent(this);
        t.setActor(this.actor);
        t.setEvent(new TrackerEvent.TraceVerb(verb));
        t.setTarget(new TrackerEvent.TraceObject(type, id));
        t.setResult(new TrackerEvent.TraceResult());
        t.Result.setExtensions(this.extensions);
        this.extensions = {};

        this.queue.push(t);
        return t;
    };

    this.Trace = function(verb,type,id) {
        return this.ActionTrace(verb,type,id);
    };

    this.flushing = false;
    this.redo_flush = false;
    this.pending_callbacks = [];
    this.Flush = function(callback) {
        if (!this.flushing) {
            this.flushing = true;
            this.DoFlush(callback);
        }else {
            this.redo_flush = true;
            this.pending_callbacks.push(callback);
        }
    };

    this.DoFlush = function(callback) {
        var tracker = this;

        tracker.CheckStatus(function(res, err) {
            if (err && res === 'Not active') {
                tracker.flushing = false;
                callback(res, err);
                return;
            }

            tracker.ProcessQueue(function(result, error) {
                if (error) {
                    tracker.flushing = false;
                    callback(result, error);
                }else if (tracker.redo_flush) {
                    tracker.redo_flush = false;
                    callback(result, error);

                    var pcs = tracker.pending_callbacks.peek(tracker.pending_callbacks.length);
                    tracker.DoFlush(pcs[0]);
                    for (var i = 1; i < pcs.length; i++) {
                        pcs[i](result, error);
                    }
                    tracker.pending_callbacks.dequeue(pcs.length);
                }else {
                    tracker.flushing = false;
                    callback(result, error);
                }
            });
        });
    };

    this.CheckStatus = function(callback) {
        if (!this.started) {
            if (this.settings.debug) {
                console.log('Refusing to send traces without starting tracker (Active is False, should be True)');
            }

            callback('Not active', true);
        } else if (!this.active) {
            if (this.settings.debug) {
                console.log('Tracker is not active, trying to reconnect.');
            }
            this.Connect(callback);
        }else {
            callback('Everything OK', false);
        }
    };

    this.ProcessQueue = function(callback) {
        var tracker = this;

        if (tracker.queue.length > 0 || tracker.tracesPending.length > 0 || tracker.tracesUnlogged.length > 0) {
            // Extract the traces from the queue and remove from the queue
            var traces = tracker.CollectTraces();

            tracker.SendAllTraces(traces, function(result, error) {
                if (tracker.settings.backupStorage && traces.length > 0) {
                    var current = tracker.LocalStorage.getItem(tracker.backup_file);
                    var rawData = tracker.ProcessTraces(traces, 'csv');

                    if (current) {
                        rawData = current + rawData;
                    }

                    tracker.LocalStorage.setItem(tracker.backup_file, rawData);
                }
                tracker.queue.dequeue(traces.length);

                callback(result, error);
            });
        } else {
            if (tracker.settings.debug) {
                console.log('Nothing to flush');
            }

            callback('Nothing to flush', false);
        }
    };

    this.SendAllTraces = function(traces, callback) {
        var tracker = this;

        if (tracker.active) {
            tracker.SendUnloggedTraces(function(unl_result, unl_error) {
                if (!unl_error) {
                    tracker.SendPendingTraces(function(pen_result, pen_error) {
                        var data = tracker.ProcessTraces(traces, 'xapi');

                        if (pen_error) {
                            if (tracker.queue.length > 0) {
                                tracker.tracesPending.push(data);
                            }

                            callback('Can\'t send pending traces', true);
                        }else if (tracker.queue.length > 0) {
                            tracker.SendTraces(data, function(result, error) {
                                if (error && tracker.queue.length > 0) {
                                    tracker.tracesPending.push(data);
                                    callback('Can\'t send Traces', true);
                                }else {
                                    callback('Everything OK', false);
                                }
                            });
                        }else {
                            callback('Everything OK', false);
                        }
                    });
                }else {
                    callback('Can\'t send Unlogged Traces', true);
                }
            });
        } else {
            tracker.tracesUnlogged = tracker.tracesUnlogged.concat(traces);
            callback('Tracker is not active', true);
        }
    };

    this.CollectTraces = function() {
        var cnt = this.settings.batch_size === 0 ? Number.MAX_SAFE_INTEGER : this.settings.batch_size;
        cnt = Math.min(this.queue.length, cnt);

        var traces = this.queue.peek(cnt);

        return traces;
    };

    this.ProcessTraces = function(traces, format) {
        var data = '';
        var item;
        var sb = [];

        for (var i = 0; i < traces.length; i++) {
            item = traces[i];

            switch (format) {
            case 'xapi': {
                sb.push(item.ToXapi());
                break;
            }
            default: {
                sb.push(item.ToCsv());
                break;
            }
        }
        }

        switch (format) {
        case 'csv': {
            data = sb.join('\r\n') + '\r\n';
            break;
        }
        case 'xapi': {
            data = '[\r\n' + sb.join(',\r\n') + '\r\n]';
            break;
        }
        default: {
            data = sb.join('\r\n');
            break;
        }
    }

        sb.length = 0;

        return data;
    };

    this.SendPendingTraces = function(callback) {
        var tracker = this;

        // Try to send old traces
        if (tracker.tracesPending.length > 0) {
            if (tracker.settings.debug) {
                console.log('Enqueued trace-blocks detected: ' + tracker.tracesPending.lenth + '. Processing...');
            }

            var data = tracker.tracesPending[0];

            tracker.SendTraces(data, function(result, error) {
                if (error) {
                    if (tracker.settings.debug) {
                        console.log('Error sending enqueued traces');
                    }
                    // Does not keep sending old traces, but continues processing new traces so that get added to tracesPending
                    callback('Error sending enqueued pending traces: \n' + result, true);
                }else {
                    tracker.tracesPending.shift();
                    if (tracker.settings.debug) {
                        console.log('Sent enqueued traces OK');
                    }

                    if (tracker.tracesPending.length > 0) {
                        tracker.SendPendingTraces(callback);
                    } else {
                        callback(result, null);
                    }
                }
            });
        }else {
            callback('Everything OK', null);
        }
    };

    this.SendUnloggedTraces = function(callback) {
        var tracker = this;

        if (tracker.tracesUnlogged.length === 0) {
            callback('Everything OK', null);
        } else if (tracker.actor === null || tracker.actor === '{}') {
            callback('Can\'t flush without actor', true);
        } else {
            var data = tracker.ProcessTraces(tracker.tracesUnlogged, 'xapi');
            tracker.SendTraces(data, function(result, error) {
                if (error) {
                    tracker.tracesPending.Add(data);
                    callback('Error sending unlogged traces', true);
                }else {
                    tracker.tracesUnlogged = [];
                    callback('Everything OK', null);
                }
            });
        }
    };

    this.SendTraces = function(data, callback) {
        var tracker = this;

        if (tracker.settings.debug) {
            console.log('Sending traces: ' + data);
        }

        this.HttpRequest(tracker.url + tracker.collector + 'track', 'post', { Authorization: tracker.auth, 'Content-Type': 'application/json' }, data,
         function (data) {
            if (tracker.settings.debug) {
                console.info(data);
            }

            tracker.connected = true;

            callback(data, null);
        },
         function (data) {
            if (tracker.settings.debug && data.responseJSON) {
                console.log(data.responseJSON);
            }
            if (tracker.settings.debug) {
                console.log('Error flushing, connection disabled temporaly');
            }

            tracker.connected = false;

            callback(data, true);
        });
    };

    this.setScore = function(raw, min, max, scaled) {
        if (exists(raw)) {
            this.setScoreRaw(raw);
        }

        if (exists(min)) {
            this.setScoreMin(min);
        }

        if (exists(max)) {
            this.setScoreMax(max);
        }

        if (exists(scaled)) {
            this.setScoreScaled(scaled);
        }
    };

    this.setScoreRaw = function(raw) {
        this.setScoreValue('raw', raw);

    };

    this.setScoreMin = function(min) {
        this.setScoreValue('min', min);
    };

    this.setScoreMax = function(max) {
        this.setScoreValue('max', max);
    };

    this.setScoreScaled = function(scaled) {
        this.setScoreValue('scaled', scaled);
    };

    this.setScoreValue = function(key, value) {
        if (!exists(this.extensions.score)) {
            this.extensions.score = {};
        }

        this.extensions.score[key] = Number(value);
    };

    this.setCompletion = function(value) {
        this.addExtension('completion', value);
    };

    this.setSuccess = function(value) {
        this.addExtension('success', value);
    };

    this.setResponse = function(value) {
        this.addExtension('response', value);
    };

    this.setProgress = function(value) {
        this.addExtension('progress', value);
    };

    this.setVar = function(key,value) {
        this.addExtension(key,value);
    };

    this.addExtension = function(key,value) {
        this.extensions[key] = value;
    };

    // PLUGINS
    this.addPlugin = function(plugin) {
        var key = null;

        for (key in plugin.functions) {
            if (key in this) {
                console.log('WARNING: Function ' + key + '() already exists in tracker, ignoring.');
                continue;
            }

            this[key] = new plugin.functions[key](this);
        }

        key = null;
        for (key in plugin.verbs) {
            if (typeof TrackerEvent.TraceVerb.VerbIDs[key] !== 'undefined') {
                console.log('WARNING: Verb ' + key + ' already exists in verbs list, ignoring.');
                continue;
            }

            TrackerEvent.TraceVerb.VerbIDs[key] = plugin.verbs[key];
        }

        key = null;
        for (key in plugin.objects) {
            if (typeof TrackerEvent.TraceObject.ObjectIDs[key] !== 'undefined') {
                console.log('WARNING: Object ' + key + ' already exists in objects list, ignoring.');
                continue;
            }

            TrackerEvent.TraceObject.ObjectIDs[key] = plugin.objects[key];
        }

        key = null;
        for (key in plugin.extensions) {
            if (typeof TrackerEvent.TraceResult.ExtensionIDs[key] !== 'undefined') {
                console.log('WARNING: Extension ' + key + ' already exists in extensions list, ignoring.');
                continue;
            }

            TrackerEvent.TraceResult.ExtensionIDs[key] = plugin.extensions[key];
        }

        key = null;
        for (key in plugin.interfaces) {
            if (key in this) {
                console.log('WARNING: Interface ' + key + ' already exists, ignoring.');
                continue;
            }

            this[key] = new plugin.interfaces[key](this);
        }
    };

    // CONNECTION
    this.HttpRequest = function(url, method, headers, body, success, error) {
        var t = this;

        var r = {
            uri: url,
            method: method,
            json: true,
            headers: headers
        };

        if (method.toLowerCase() === 'post') {
            if (body === '') {
                r.body = {};
            } else {
                r.body = JSON.parse(body);
            }
        }

        request(r, function (err, httpResponse, body) {
            if (err || httpResponse && httpResponse.statusCode !== 200) {
                if (t.settings.debug) {
                    console.log('Error:', err, 'Status code:', httpResponse ? httpResponse.statusCode : -1, 'Body', body);
                }
                return error({ responseJSON: err ? err : body });
            }
            success(body);
        });
    };

    this.LocalStorage = {
        getItem: function(name) {
            return localStorage.getItem(name);
        },
        setItem: function(name, data) {
            localStorage.setItem(name, data);
        }
    };
}

var obsize = function(obj) {
    var size = 0, key;
    for (key in obj) {
        if (obj.hasOwnProperty(key)) {
            size++;
        }
    }
    return size;
};

var ismap = function(obj) {
    for (var key in obj) {
        if (typeof obj[key] === 'object') {
            return false;
        }
    }
    return true;
};

function TrackerEvent (tracker) {
    this.tracker = tracker;

    this.TimeStamp = Date.now();

    this.Actor = null;
    this.Event = null;
    this.Target = null;
    this.Result = null;

    this.setActor = function(actor) {
        this.Actor = actor;
    };

    this.setEvent = function(event) {
        this.Event = event;
        this.Event.parent = this;
    };

    this.setTarget = function(target) {
        this.Target = target;
        this.Target.parent = this;
    };

    this.setResult = function(result) {
        this.Result = result;
        this.Result.parent = this;
    };

    this.ToCsv = function() {
        return this.TimeStamp +
         ',' + this.Event.ToCsv() +
         ',' + this.Target.ToCsv() +
         (this.Result === null && this.Result.ToCsv() ? '' : this.Result.ToCsv());
    };

    this.ToXapi = function() {
        var t = {
            actor: this.Actor === null || this.Actor === '{}' ? (this.tracker.actor === null ? {} : this.tracker.actor) : this.Actor,
            verb: this.Event.ToXapi(),
            object: this.Target.ToXapi(),
            timestamp: moment().toISOString()
        };

        if (this.Result !== null) {
            var result = this.Result.ToXapi();
            if (obsize(result) > 0) {
                t.result = result;
            }
        }

        return JSON.stringify(t, null, 4);
    };
}

// ##############################################
// ################ NESTED TYPES ################
// ##############################################

// Trace Verb

TrackerEvent.TraceVerb = function(verb) {
    this.verb = verb;

    this.ToCsv = function() {
        return this.verb.replaceAll(',', '\\,');
    };

    this.ToXapi = function() {
        if (TrackerEvent.TraceVerb.VerbIDs.hasOwnProperty(this.verb)) {
            return { id: TrackerEvent.TraceVerb.VerbIDs[this.verb] };
        }

        return { id: this.verb };
    };
};

TrackerEvent.TraceVerb.VerbIDs = {
    initialized: 'http://adlnet.gov/expapi/verbs/initialized',
    progressed: 'http://adlnet.gov/expapi/verbs/progressed',
    completed: 'http://adlnet.gov/expapi/verbs/completed',
    accessed: 'https://w3id.org/xapi/seriousgames/verbs/accessed',
    skipped: 'http://id.tincanapi.com/verb/skipped',
    selected: 'https://w3id.org/xapi/adb/verbs/selected',
    unlocked: 'https://w3id.org/xapi/seriousgames/verbs/unlocked',
    interacted: 'http://adlnet.gov/expapi/verbs/interacted',
    used: 'https://w3id.org/xapi/seriousgames/verbs/used'
};

// Trace Target

TrackerEvent.TraceObject = function(type, id) {
    this.parent = null;

    this.Type = type;
    this.ID = id;

    this.ToCsv = function() {
        return this.Type.replaceAll(',','\\,') + ',' + this.ID.replaceAll(',', '\\,');
    };

    this.ToXapi = function() {
        var typeKey = this.Type;

        if (TrackerEvent.TraceObject.ObjectIDs.hasOwnProperty(this.Type)) {
            typeKey = TrackerEvent.TraceObject.ObjectIDs[this.Type];
        }

        id = ((this.parent.tracker.objectId !== null) ? this.parent.tracker.objectId + '/' + this.Type.toLowerCase() + '/' : '') + this.ID;
        var definition = {type: typeKey};

        return {
            id: id,
            definition: definition
        };
    };
};

TrackerEvent.TraceObject.ObjectIDs = {
    // Completable
    game: 'https://w3id.org/xapi/seriousgames/activity-types/serious-game' ,
    session: 'https://w3id.org/xapi/seriousgames/activity-types/session',
    level: 'https://w3id.org/xapi/seriousgames/activity-types/level',
    quest: 'https://w3id.org/xapi/seriousgames/activity-types/quest',
    stage: 'https://w3id.org/xapi/seriousgames/activity-types/stage',
    combat: 'https://w3id.org/xapi/seriousgames/activity-types/combat',
    storynode: 'https://w3id.org/xapi/seriousgames/activity-types/story-node',
    race: 'https://w3id.org/xapi/seriousgames/activity-types/race',
    completable: 'https://w3id.org/xapi/seriousgames/activity-types/completable',

    // Acceesible
    screen: 'https://w3id.org/xapi/seriousgames/activity-types/screen' ,
    area: 'https://w3id.org/xapi/seriousgames/activity-types/area',
    zone: 'https://w3id.org/xapi/seriousgames/activity-types/zone',
    cutscene: 'https://w3id.org/xapi/seriousgames/activity-types/cutscene',
    accessible: 'https://w3id.org/xapi/seriousgames/activity-types/accessible',

    // Alternative
    question: 'http://adlnet.gov/expapi/activities/question' ,
    menu: 'https://w3id.org/xapi/seriousgames/activity-types/menu',
    dialog: 'https://w3id.org/xapi/seriousgames/activity-types/dialog-tree',
    path: 'https://w3id.org/xapi/seriousgames/activity-types/path',
    arena: 'https://w3id.org/xapi/seriousgames/activity-types/arena',
    alternative: 'https://w3id.org/xapi/seriousgames/activity-types/alternative',

    // GameObject
    enemy: 'https://w3id.org/xapi/seriousgames/activity-types/enemy' ,
    npc: 'https://w3id.org/xapi/seriousgames/activity-types/non-player-character',
    item: 'https://w3id.org/xapi/seriousgames/activity-types/item',
    gameobject: 'https://w3id.org/xapi/seriousgames/activity-types/game-object'
};

// Trace Target

TrackerEvent.TraceResult = function() {
    this.parent = null;

    this.Score = null;
    this.Success = null;
    this.Completion = null;
    this.Response = null;
    this.Extensions = null;

    this.setExtensions = function(extensions) {
        this.Extensions = {};

        for (var key in extensions) {
            switch (key.toLowerCase()) {
            case 'success': {       this.Success = extensions[key]; break; }
            case 'completion': {    this.Completion = extensions[key]; break; }
            case 'response': {      this.Response = extensions[key]; break; }
            case 'score': {         this.Score = extensions[key]; break; }
            default: {              this.Extensions[key] = extensions[key]; break; }
        }
        }
    };

    this.ToCsv = function() {
        var success = (this.Success !== null) ? ',success,' + this.Success.toString() : '';
        var completion = (this.Completion !== null) ? ',completion,' + this.Completion.toString() : '';
        var response = (this.Response) ? ',response,' + this.Response.replaceAll(',', '\\,') : '';
        var score = '';

        if (exists(this.Score)) {
            if (exists(this.Score.raw)) {
                score += ',score,' + this.Score.raw;
            }

            if (exists(this.Score.min)) {
                score += ',score_min,' + this.Score.min;
            }

            if (exists(this.Score.max)) {
                score += ',score_max,' + this.Score.max;
            }

            if (exists(this.Score.scaled)) {
                score += ',score_scaled,' + this.Score.scaled;
            }
        }

        var result = success + completion + response + score;

        if (this.Extensions !== null && obsize(this.Extensions) > 0) {
            for (var key in this.Extensions) {
                result += ',' + key.replaceAll(',', '\\,') + ',';
                if (this.Extensions[key] !== null) {
                    if (typeof this.Extensions[key] === 'number') {
                        result += this.Extensions[key];
                    } else if (typeof this.Extensions[key] === 'string') {
                        result += this.Extensions[key].replaceAll(',', '\\,');
                    } else if (typeof this.Extensions[key] === 'object') {
                        if (ismap(this.Extensions[key])) {
                            var smap = '';

                            for (var k in this.Extensions[key]) {
                                if (typeof this.Extensions[key][k] === 'number') {
                                    smap += k + '=' + this.Extensions[key][k] + '-';
                                } else {
                                    smap += k + '=' + this.Extensions[key][k].replaceAll(',', '\\,') + '-';
                                }
                            }

                            result += smap.slice(0,-1);
                        }
                    } else {
                        result += this.Extensions[key];
                    }
                }
            }
        }

        return result;
    };

    this.ToXapi = function() {
        var ret = {};

        if (this.Success !== null) {
            ret.success = (this.Success) ? true : false;
        }

        if (this.Completion !== null) {
            ret.completion = (this.Completion) ? true : false;
        }

        if (this.Response) {
            ret.response = this.Response.toString();
        }

        if (this.Score !== null) {
            ret.score = this.Score;
        }

        if (this.Extensions !== null && obsize(this.Extensions) > 0) {
            ret.extensions = this.Extensions;

            for (var key in this.Extensions) {
                if (TrackerEvent.TraceResult.ExtensionIDs.hasOwnProperty(key)) {
                    this.Extensions[TrackerEvent.TraceResult.ExtensionIDs[key]] = this.Extensions[key];
                    delete this.Extensions[key];
                }
            }
        }

        return ret;
    };
};

TrackerEvent.TraceResult.ExtensionIDs = {
    health: 'https://w3id.org/xapi/seriousgames/extensions/health',
    position: 'https://w3id.org/xapi/seriousgames/extensions/position',
    progress: 'https://w3id.org/xapi/seriousgames/extensions/progress'
};

// #################################################
// ################ XAPI INTERFACES ################
// #################################################

var Accessible = function(tracker) {

    this.tracker = tracker;

    this.AccessibleType = {
        Screen: 0,
        Area: 1,
        Zone: 2,
        Cutscene: 3,
        Accessible: 4,
        properties: ['screen', 'area', 'zone', 'cutscene', 'accessible']
    };

    this.Accessed = function(accessibleId, type) {
        if (typeof type === 'undefined') {type = 4;}

        return this.tracker.Trace('accessed',this.AccessibleType.properties[type],accessibleId);
    };

    this.Skipped = function(accessibleId, type) {
        if (typeof type === 'undefined') {type = 4;}

        return this.tracker.Trace('skipped',this.AccessibleType.properties[type],accessibleId);
    };
};

var Alternative = function(tracker) {

    this.tracker = tracker;

    this.AlternativeType = {
        Question: 0,
        Menu: 1,
        Dialog: 2,
        Path: 3,
        Arena: 4,
        Alternative: 5,
        properties: ['question', 'menu', 'dialog', 'path', 'arena', 'alternative']
    };

    this.Selected = function(alternativeId, optionId, type) {
        if (typeof type === 'undefined') {type = 5;}

        this.tracker.setResponse(optionId);
        return this.tracker.Trace('selected',this.AlternativeType.properties[type],alternativeId);
    };

    this.Unlocked = function(alternativeId, optionId, type) {
        if (typeof type === 'undefined') {type = 5;}

        this.tracker.setResponse(optionId);
        return this.tracker.Trace('unlocked',this.AlternativeType.properties[type],alternativeId);
    };
};

var Completable = function(tracker) {

    this.tracker = tracker;

    this.CompletableType = {
        Game: 0,
        Session: 1,
        Level: 2,
        Quest: 3,
        Stage: 4,
        Combat: 5,
        StoryNode: 6,
        Race: 7,
        Completable: 8,
        properties: ['game', 'session', 'level', 'quest', 'stage', 'combat', 'storynode', 'race', 'completable']
    };

    this.Initialized = function(completableId, type) {
        if (typeof type === 'undefined') {type = 8;}

        return this.tracker.Trace('initialized',this.CompletableType.properties[type],completableId);
    };

    this.Progressed = function(completableId, type, progress) {
        if (typeof type === 'undefined') {type = 8;}

        this.tracker.setProgress(progress);
        return this.tracker.Trace('progressed',this.CompletableType.properties[type],completableId);
    };

    this.Completed = function(completableId, type, success, score) {
        if (typeof type === 'undefined') {type = 8;}
        if (typeof success === 'undefined') {success = true;}
        if (typeof score === 'undefined') {score = 1;}

        this.tracker.setSuccess(success);
        this.tracker.setScore(score);
        return this.tracker.Trace('completed',this.CompletableType.properties[type],completableId);
    };
};

var GameObject = function(tracker) {

    this.tracker = tracker;

    this.GameObjectType = {
        Enemy: 0,
        Npc: 1,
        Item: 2,
        GameObject: 3,
        properties: ['enemy', 'npc', 'item', 'gameobject']
    };

    this.Interacted = function(gameobjectId, type) {
        if (typeof type === 'undefined') {type = 3;}

        return this.tracker.Trace('interacted',this.GameObjectType.properties[type],gameobjectId);
    };

    this.Used = function(gameobjectId, type) {
        if (typeof type === 'undefined') {type = 3;}

        return this.tracker.Trace('used',this.GameObjectType.properties[type],gameobjectId);
    };
};

String.prototype.replaceAll = function(search, replacement) {
    var target = this;
    return target.replace(new RegExp(search, 'g'), replacement);
};

Array.prototype.peek = function(n) {
    n = Math.min(this.length, n);

    var tmp = [];

    for (var i = 0; i < n; i++) {
        tmp.push(this[i]);
    }

    return tmp;
};

Array.prototype.dequeue = function(n) {
    n = Math.min(this.length, n);

    var tmp = [];

    for (var i = 0; i < n; i++) {
        tmp.push(this.shift());
    }

    return tmp;
};

var exists = function(value) {
    return !(typeof value === 'undefined' || value === null);
};

module.exports = TrackerAsset;