Source: Session.js

// @flow

const Participant   = require('./Participant.js');
const App           = require('./App.js');
const Period        = require('./Period.js');
const Group         = require('./Group.js');
const Player        = require('./Player.js');
const Client        = require('./Client.js');
const Table         = require('./Table.js');
const Utils         = require('./Utils.js');
const fs            = require('fs-extra');
const path          = require('path');
const async         = require('async');

const PLAYER_STATUS_FINISHED = 'finished';
const PLAYER_STATUS_PLAYING = 'playing';
const PLAYER_STATUS_READY = 'ready';

/**
* A session is a collection of apps and players.
*/
class Session {

    /**
    * Create a Session.
    *
    * CALLED FROM:
    * - {@link Session#load}
    * - {@link Data#constructor}
    * - {@link Data#createSession}
    *
    * @param  {Object} jt         Server object.
    * @param  {type} id               The id of the new session.
    */
    constructor(jt, id, options) {

        if (options == null) {
            options = {};
        }

        if (options.createFolder == null) {
            options.createFolder = true;
        }

        this.jt = jt;
        this.id = id;
        if (this.id === null || this.id === undefined) {
            this.id = Utils.getDate();
        }
        this.name = this.id;

        /**
         * The options of this app.
         * @type Array
         * @default []
         */
        this.options = [];

        /**
         * The option values of this app.
         * @type Object
         * @default {}
         */
        this.optionValues = {};

        /**
        * A list of the clients connected to this session.
        * @type Array
        */
        this.clients = [];

        this.potentialParticipantIds = this.jt.settings.participantIds;

        this.caseSensitiveLabels = false;

        this.suggestedNumParticipants = jt.settings.session.suggestedNumParticipants;

        /**
        * A list of participants in this session.
        * @type Object
        */
        this.participants = {};

        /**
        * The time at which this session was last started.
        * @type number
        */
        this.timeStarted = 0;
        this.started = false;

        /**
        * Whether or not clients can create a participant that does not exist yet.
        */
        this.allowNewParts = this.jt.settings.allowClientsToCreateParticipants;

        this.outputDelimiter = this.jt.settings.outputDelimiter;

        /**
        * The apps in this session.
        * @type Array
        */
        this.apps = [];

        this.users = [];

        /**
         * Whether or not admin windows can play.
         * Generally, during testing this can be test
         */
        this.allowAdminClientsToPlay = true;

        this.asyncQueue = async.queue(this.processMessage, 1);

        // A filestream for writing to this session's object states.
        try {
            fs.ensureDirSync(this.getOutputDir());
            var options = { 'flags': 'a'};
            this.fileStream = fs.createWriteStream(this.getOutputDir() + '/' + this.id + '.gsf', options);
        } catch (err) {
            debugger;
            console.log(err);
        }
        /**
        * A list of fields to hide from output.
        * @type Array
        */
        this.outputHide = [
            'jt',
            'clients',
            'participants',
            'this',
            'outputHide',
            'apps',
            'fileStream',
            'asyncQueue',
            // 'started',
            'emitMessages',
            'queue',
        ];

        this.emitMessages = true;

    }

    /**
    * Loads a session from a .json file.
    *
    * FUNCTIONALITY
    * - create a new session
    * - load session objects from .gsf file
    * - for each object in the .gsf file, call the appropriate object.load method.
    * - link participants and players.
    *
    * @param  {Object} jt      The server
    * @param  {type} json      The content of the session.
    * @return {Session}        The session described by the contents of json.
    */
    static load(jt, folder, data) {
        var session = new Session(jt, folder);
        var all = fs.readFileSync(path.join(jt.path, jt.settings.sessionsFolder + '/' + folder + '/' + folder + '.gsf')).toString();
        var lines = all.split('\n');

        // Read objects
        for (var i=0; i<lines.length; i++) {
            try {
                if (lines[i] !== undefined && lines[i].length > 0) {
                    var json = JSON.parse(lines[i]);
                    switch (json.type) {
                        case 'SESSION':
                        var newSession = new Session(jt, folder);
                        for (var j in json) {
                            newSession[j] = json[j];
                        }
                        newSession.participants = session.participants;
                        for (let p in session.participants) {
                            session.participants[p].session = newSession;
                        }

                        newSession.apps = [];
                        // var extra = 0;
                        // for (var j in newSession.appSequence) {
                        //     var k = j - extra;
                        //     if (session.apps[k].id === newSession.appSequence) {
                        //         var app = session.apps[k];
                        //         app.session = newSession;
                        //         newSession.apps.push(app);
                        //     } else {
                        //         extra++;
                        //     }
                        // }

                        session = newSession;

                        break;
                        case 'APP':
                        App.load(json, session);
                        break;
                        case 'PERIOD':
                        Period.load(json, session);
                        break;
                        case 'GROUP':
                        Group.load(json, session, data);
                        break;
                        case 'PLAYER':
                        Player.load(json, session);
                        break;
                        case 'PARTICIPANT':
                        Participant.load(json, session);
                        break;
                        case 'TABLE':
                        Table.load(json, session);
                        break;
                    };
                }
            } catch (err) {
//                console.log(err.stack);
            }
        }

        // Link participants and players.
        for (var a in session.apps) {
            var app = session.apps[a];
            for (var prd in app.periods) {
                var period = app.periods[prd];
                for (var gr in period.groups) {
                    var group = period.groups[gr];
                    for (var pl in group.players) {
                        var player = group.players[pl];
                        var participant = session.participants[player.participantId];
                        player.participant = participant;
                        participant.player = player;
                        participant.players.push(player);
                        if (player.stageIndex > -1) {
                            player.stage = player.app().stages[player.stageIndex];
                        }
                    }
                }
            }
        }

        return session;
    }

    canUserManage(userId) {
        var user = this.jt.data.user(userId);
        if (!this.jt.settings.multipleUsers) {
            return true;
        } else {
            if (user === null) {
                return false;
            } else if (user.isAdmin()) {
                return true;
            } else {
                return this.users.includes(userId);
            }
        }
    }

    /**
    * Add the app with the given ID to this session.
    *
    * FUNCTIONALITY:
    * - load the given app {@link Session#loadApp}
    * - add app to this session's apps field.
    * - copy app source files {@link Utils#copyFiles}.
    * - save app and its stages {@link App#saveSelfAndChildren}.
    * - emit 'sessionAddApp' message.
    *
    * @param  {string} appId The ID of the app to add to this session.
    */
    addApp(appPath, options) {

        if (this.queuePath != null && !appPath.startsWith(this.queuePath)) {
            appPath = path.join(this.queuePath, appPath);
        }

        try {
            var app = this.jt.data.loadApp(appPath, this, appPath, options);
            if (app !== null) {
                this.apps.push(app);
                if (app.appPath.endsWith('.jtt') || app.appPath.endsWith('.js')) {
                    Utils.copyFile(app.appFilename, app.appDir, app.getOutputFN());
                } else {
                    Utils.copyFiles(path.parse(app.appPath).dir, app.getOutputFN());
                }
                if (this.apps.length == 1 &&
                    app.suggestedNumParticipants != null &&
                    this.suggestedNumParticipants == null) {
                        this.suggestedNumParticipants = app.suggestedNumParticipants;
                        this.setNumParticipants(app.suggestedNumParticipants);
                }
                // app.saveSelfAndChildren();
                this.save();
                if (this.emitMessages) {
                    this.emit('sessionAddApp', {sId: this.id, app: app.shellWithChildren()});
                }
            }
            return app;
        } catch (err) {
            debugger;
        }
    }

    addUser(userId) {
        this.users.push(userId);
        this.save();
        this.emit('sessionAddUser', {sId: this.id, uId: userId});
    }

    addQueue(qId) {
        var queue = this.jt.data.queue(qId);
        if (queue !== null) {
            for (var i in queue.apps) {
                try {
                    this.addApp(queue.apps[i].appId, queue.apps[i].options);
                } catch (err) {}
            }
        }
    }

    addAdminClient(socket) {
        for (let i=0; i<this.participants.length; i++) {
            addClient(socket, this.participants[i].id);
        }
    }

    /**
    * Connect a web socket client to a participant of this session.
    * - Listen for disconnect and goto-next-stage messages.
    * - Load participant.
    * - Socket listens to its own channel.
    * -
    *
    * CALLED FROM
    * - {@link SocketServer#addParticipantClient}
    *
    * @param  {Object} socket        The web socket.
    * @param  {string} participantId The id of the participant.
    */
    addClient(socket, participantId) {
        var session = this;

        socket.on('disconnect', function() {
            session.clientRemove(socket);
        });

        socket.on('goto-next-stage', function(msg) {
            var pId = msg.pId;
            var stageId = msg.stageId;
            var periodId = msg.periodId;
            var participant = session.participant(pId);
            if (participant.stageId() === stageId && participant.player.periodIndex() === periodId) {
                participant.getApp().playerMoveToNextStage(participant.player);
            }
        });

        var participant = this.participant(participantId);
        if (participant === null || participant === undefined) {
            if (!this.allowNewParts) {
                console.log('error: tried to add new participant ' + participantId);
                this.io().to(socket.id).emit('notLoggedIn');
                return null;
            }
            participant = this.participantCreate(participantId);
        }

        var client = new Client.new(socket, this);
        client.participant = participant;
        socket.join(this.roomId());
        participant.clientAdd(client);
        this.clients.push(client);
        this.jt.socketServer.sendOrQueueAdminMsg(null, 'addClient', client.shell());
        this.io().to(socket.id).emit('logged-in', participant.shell());
        if (participant.player !== null) {
            participant.player.sendUpdate(socket.id);
        }

        return client;
    }

    emitToAdmins(name, data) {
        this.jt.socketServer.sendOrQueueAdminMsg(null, name, data);
    }

    getOutputDir() {
        return this.jt.settings.sessionsFolder + '/' + this.name;
    }

    /**
    * Pushes a message to the end of the message queue.
    * @param  {type} obj The client from whom the message was received.
    * @param  {type} da The data received from the client.
    * @param  {string} funcName The name of the function to evaluate on the client object.
    */
    pushMessage(obj, da, funcName) {
        var msg = {obj: obj, data: da, fn: funcName, jt: this.jt, session: this};
        this.asyncQueue.push(msg, this.messageCallback);
        //        var playerId = Player.genRoomId(da.player);
        //        var line = cl.participant.id + ', ' + cl.id + ', ' + playerId + ', ' + funcName + ', ' + JSON.stringify(da.data) + '\n';
        //        fs.appendFileSync(this.getOutputDir() + '/messages.csv', line);
    }

    addMessageToStartOfQueue(obj, data, funcName) {
        var msg = {obj: obj, data: data, fn: funcName, jt: this.jt, session: this};
        this.asyncQueue.unshift(msg, this.messageCallback);
        //        var playerId = Player.genRoomId(da.player);
        //        var line = cl.participant.id + ', ' + cl.id + ', ' + playerId + ', ' + funcName + ', ' + JSON.stringify(da.data) + '\n';
        //        fs.appendFileSync(this.getOutputDir() + '/messages.csv', line);
    }

    processMessage(msg, callback) {
        let obj = msg.obj;
        let data = msg.data;
        let fn = msg.fn;
        let jt = msg.jt;
        let session = msg.session;
        try {

            if (!['endStage', 'endApp', 'forceEndStage'].includes(fn)) {
                data = Utils.parseFloatRec(data);
            }
            
            if (obj.canProcessMessage()) {
                obj[fn](data);
                session.emitParticipantUpdates();
                //            }
                //            if (client.player() !== null && client.player().matchesPlayer(player) && client.player().status === 'playing') {
                //                if (client.player() !== null) {
                //                    client.player().save();
                //                }
            } else {
                jt.log('Object cannot process message, skipping message "' + fn + '".');
            }
        } catch (err) {
            jt.log(err.stack);
            debugger;
            try {
                jt.log('error processing message: ' + JSON.stringify(msg.data));
            } catch (err2) {
                jt.log('Error printing error: ' + msg.fn + ', ' + err2.stack);
            }
        }
        callback();
        return true;
    }

    /**
    // * Process a message from the queue.
    // */
    // processMessageV1(msg, callback) {
    //     const client = msg.client;
    //     const player = msg.data.player;
    //     let data = msg.data.data;
    //     const fn = msg.fn;
    //     const jt = msg.jt;
    //     try {
    //         if (client.player() !== null && client.player().matchesPlayer(player) && client.player().status === 'playing') {
    //             data = Utils.parseFloatRec(data);
    //             client[fn](data);
    //             //                if (client.player() !== null) {
    //             //                    client.player().save();
    //             //                }
    //         } else {
    //             jt.log('Message player does not match clients current player, skipping message.');
    //         }
    //     } catch (err) {
    //         jt.log('error processing message: ' + JSON.stringify(msg.data));
    //         jt.log(err.stack);
    //     }
    //     callback();
    //     return true;
    // }

    messageCallback() {}

    resume() {
        this.setRunning(true);
    }

    pause() {
        this.setRunning(false);
    }

    setRunning(b) {
        this.isRunning = b;
        var timers = this.timers();
        for (var t in timers) {
            timers[t].setRunning(b);
        }
        for (var i in this.participants) {
            var participant = this.participants[i];
            if (participant.player != null) {
                participant.player.emitUpdate2();
            }
        }
    }

    setId(id) {
        this.id = id;
        for (var i in this.participants) {
            var participant = this.participants[i];
            participant.refreshClients();
        }
    }

    reset() {
        this.started = false;
        for (let i in this.participants) {
            let participant = this.participants[i];
            participant.reset();
        }
        for (let i=0; i<this.apps.length; i++) {
            let app = this.apps[i];
            this.apps[i] = app.reload();
        }
    }

    timers() {
        var out = [];
        for (var a in this.apps) {
            var app = this.apps[a];
            for (var p in app.periods) {
                var period = app.periods[p];
                for (var g in period.groups) {
                    var group = period.groups[g];
                    if (group.stageTimer !== undefined) {
                        out.push(group.stageTimer);
                    }
                    for (var pl in group.players) {
                        var player = group.players[pl];
                        if (player.stageTimer !== undefined) {
                            out.push(player.stageTimer);
                        }
                    }
                }
            }
        }
        return out;
    }

    /**
    * Returns the {@link App} in the session app sequence that follows a reference {@link App}.
    * @param  {@link App} app The reference app.
    * @return {@link App}     The app in the session app sequence that follows the reference app.
    */
    appFollowing(app) {
        for (var i=0; i<this.apps.length; i++) {
            if (this.apps[i] === app) {
                if (i < this.apps.length-1) {
                    return this.apps[i+1];
                } else {
                    return null;
                }
            }
        }
    }

    participantUI() {
        return this.jt.settings.participantUI;
    }

    addNumberOption(name, defaultVal, min, max, step, description) {
        // Add to list of options.
        this.options.push({
            type: 'number',
            name: name,
            min: min,
            max: max,
            step: step,
            defaultVal: defaultVal,
            description: description
        });

        // Add value, if does not already exist.
        if (this[name] === undefined) {
            this[name] = defaultVal;
        }

        // Value already exists, coerce if possible into one of the original option values.
        else {
            this.setOptionValue(name, this[name]);
        }
    }

    addTextOption(name, defaultVal, description) {
        // Add to list of options.
        this.options.push({
            type: 'text',
            name: name,
            defaultVal: defaultVal,
            description: description
        });

        // Add value, if does not already exist.
        if (this[name] === undefined) {
            this[name] = defaultVal;
        }

        // Value already exists, coerce if possible into one of the original option values.
        else {
            this.setOptionValue(name, this[name]);
        }
    }

    addSelectOption(optionName, optionVals, description) {

        // Add to list of options.
        this.options.push({
            type: 'select',
            name: optionName,
            values: optionVals,
            description: description
        });

        // Add value, if does not already exist.
        if (this[optionName] === undefined) {
            this[optionName] = optionVals[0];
        }

        // Value already exists, coerce if possible into one of the original option values.
        else {
            this.setOptionValue(optionName, this[optionName]);
        }
    }

    /**
    * Sends a page to a client (via the given HTTPResponse object).
    * @param  {HTTPRequest} req description
    * @param  {HTTPResponse} res description
    */
    sendParticipantPage(req, res, participantId) {
        var participant = this.participant(participantId);

        // Not a participant yet
        if (participant == null) {
            res.sendFile(path.join(this.jt.path, this.participantUI() + '/readyClient.html'));
        }
        // Participant, but not in an app yet.
        else if (participant.getApp() == null) {
            res.sendFile(path.join(this.jt.path, this.participantUI() + '/readyClient.html'));
        }
        // participant in an app.
        else {
            const app = participant.getApp();
            app.sendParticipantPage(req, res, participant);
        }
    }

    /**
    * Adds a particular number of participants to this session.
    * @param  {number} num The number of participants to add.
    */
    addParticipants(num) {
        let partsAdded = 0;

        // Search through the list of participantIds until one is found for which
        // no participant already exists.
        for (var i=0; i<this.potentialParticipantIds.length; i++) {
            let pId = this.potentialParticipantIds[i];
            let ptcptAlreadyExists = this.participants[pId] !== undefined;

            // No participant already exists, so create one.
            if (!ptcptAlreadyExists) {
                this.participantCreate(pId);
                partsAdded++;

                // Check if enough participants have been created. If yes, exit.
                if (partsAdded >= num) {
                    return;
                }
            }
        }
    }

    saveOutput() {

        var headers = ['id'];
        var skip = [];
        var fields = this.outputFields();
        Utils.getHeaders(fields, skip, headers);
        var text = [];
        text.push(headers.join(this.outputDelimiter));
        var newLine = '';
        for (var h=0; h<headers.length; h++) {
            var header = headers[h];
            if (this[header] !== undefined) {
                newLine += JSON.stringify(this[header]);
            }
            if (h<headers.length-1) {
                newLine += this.outputDelimiter;
            }
        }
        text.push(newLine);

        var fn = this.csvFN() + ' - manual save at ' + Utils.getDate() + '.csv';
        let fullText = '';
        let fd = fs.openSync(fn, 'a');
        try {
            fullText += 'SESSION\n';
            fullText += text.join('\n') + '\n';
            for (var i=0; i<this.apps.length; i++) {
                fullText += this.apps[i].saveOutput();
            }
            fs.appendFileSync(fd, fullText);
        } catch(err) {
            console.log('error writing session: ' + this.id);
            debugger;
        } finally {
            fs.closeSync(fd);
        }

        return fs.readFileSync(fn, 'utf8');
    }

    emitParticipantUpdates() {
        // console.log('emitting updates');
        for (let p in this.participants) {
            this.participants[p].actuallyEmitUpdate();
        }
    }

    /**
    * Sets the number of participants in this session.
    * @param  {number} num The number of participants to have.
    */
    setNumParticipants(num) {
        let change = num - Object.keys(this.participants).length;
        if (change > 0) {
            this.addParticipants(change);
        } else if (change < 0) {
            this.removeParticipants(-change);
        }
    }

    setAllowNewParts(b) {
        this.allowNewParts = b;
        var d = {sId: this.id, value: b};
        this.emit('setAllowNewParts', d);
    }

    setAllowAdminPlay(b) {
        this.allowAdminClientsToPlay = b;
        let data = {sId: this.id, value: b};
        this.emit('setAllowAdminPlay', data);
    }

    setCaseSensitiveLabels(b) {
        this.caseSensitiveLabels = b;
        var d = {sId: this.id, value: b};
        this.emit('setCaseSensitiveLabels', d);
    }

    /**
    * Remove a particular number of participants from this session.
    * @param  {number} num The number of participants to remove.
    */
    removeParticipants(num) {
        const parts = this.participants;
        for (let i=0; i<num; i++) {
            let len = Object.keys(parts).length;

            if (len < 1) {
                return;
            }

            let part = parts[Object.keys(parts)[len-1]];
            let pId = part.id;
            this.deleteParticipant(pId);
        }
    }

    deleteParticipant(pId) {
        delete this.participants[pId];
        let md = {sId: this.id, pId: pId};
        this.emit('sessionDeleteParticipant', md);
    }

    /**
    * this - description
    *
    * @param  {type} d description
    */
    deleteApp(d) {
        var i = parseInt(d.i);
        if (i > -1 && this.apps[i].id === d.aId) {
            this.apps.splice(i, 1);
            var toDel = this.getOutputDir() + '/' + (i + 1) + '_' + d.aId;
            if (fs.existsSync(toDel)) {
                fs.removeSync(toDel);
            }
            for (var j=i; j<this.apps.length; j++) {
                var origPath = this.getOutputDir() + '/' + (j + 2) + '_' + this.apps[j].id;
                var newPath = this.getOutputDir() + '/' + (j + 1) + '_' + this.apps[j].id;
                fs.renameSync(origPath, newPath);
            }
            this.save();
            this.emit('sessionDeleteApp', d);
        }
    }

    setAppOption(appId, appIndex, name, value) {
        var i = parseInt(appIndex);
        if (i > -1 && this.apps[i].id === appId) {
            var app = this.apps[i];
            app.setOptionValue(name, value);
            this.apps[i] = app.reload();
            app.saveSelfAndChildren();
        }
    }

    setOptionValue(name, value) {
        this.optionValues[name] = value;
        var correctedValue = value;
        var isValid = false;
        let foundOpt = false;
        for (var opt in this.options) {
            var option = this.options[opt];
            if (option.name === name) {
                foundOpt = true;
                if (option.type === 'select') {
                    for (var i in option.values) {
                        if (option.values[i] == value) { // allow for coercion
                            correctedValue = option.values[i];
                            isValid = true;
                            break;
                        }
                    }
                } else if (option.type === 'number') {
                    correctedValue = value - 0; /** coerce to number*/
                    isValid = true;
                    break;
                } else if (option.type === 'text') {
                    isValid = true;
                    // no correction needed.
                }
            }
        }
        if (isValid || !foundOpt) {
            this[name] = correctedValue;
        }
    }


    /**
    * Sends a message to all clients of this session.
    *
    * @param  {string} name The name of the message.
    * @param  {Object} d    The data of the message.
    */
    emit(name, d) {
        this.io().to(this.roomId()).emit(name, d);
    }

    /**
    * Move slowest participants to their next stage. See {@link Participant#moveToNextStage}.
    *
    */
    advanceSlowest() {
        var parts = this.slowestParticipants();
        for (var i=0; i<parts.length; i++) {
            parts[i].moveToNextStage();
        }
    }

    slowestParticipants() {
        var out = [];
        var minAppIndex = null;
        var minPeriodIndex = null;
        var minStageIndex = null;
        for (var i in this.participants) {
            var part = this.participants[i];
            if (minAppIndex === null || part.appIndex <= minAppIndex) {
                if (minPeriodIndex === null || part.periodIndex <= minPeriodIndex) {
                    if (minStageIndex === null || part.stageIndex() <= minStageIndex) {
                        if (minAppIndex === null || part.appIndex < minAppIndex) {
                            minAppIndex = part.appIndex;
                            minPeriodIndex = part.periodIndex;
                            minStageIndex = part.stageIndex();
                            out = [];
                        } else if (minPeriodIndex === null || part.periodIndex < minPeriodIndex) {
                            minPeriodIndex = part.periodIndex;
                            minStageIndex = part.stageIndex();
                            out = [];
                        } else if (minStageIndex === null || part.stageIndex() < minStageIndex) {
                            minStageIndex = part.stageIndex();
                            out = [];
                        }
                        out.push(part);
                    }
                }
            }
        }
        return out;
    }

    /**
    * this - description
    *
    * @return {type}  description
    */
    save() {
        try {
            this.jt.log('Session.save: ' + this.id);
            var localData = this.shell();
            this.saveDataFS(localData, 'SESSION');
            for (var i in this.apps) {
                this.apps[i].saveSelfAndChildren();
            }
        } catch (err) {
            console.log(err.stack);
        }
    }

    saveDataFS(d, type) {
        try {
            var a = JSON.stringify(d) + '\n';
            var b = '"type":"' + type + '"' + this.outputDelimiter;
            var position = 1;
            var output = [a.slice(0, position), b, a.slice(position)].join('');
            this.fileStream.write(output);
        } catch (err) {
            console.log('ERROR Session.saveDataFS: ' + err.stack);
        }
    }

    /**
    * Creates a top-down shell of this {@link Session}. This includes all fields given by {@link Session.outputFields}, the participants, the apps and the clients.
    *
    * CALLED FROM:
    * - {@link Msgs#openSession}.
    *
    * @return {type}  The shell of this session.
    */
    shellWithChildren() {
        var out = {};
        var fields = this.outputFields();
        for (var f in fields) {
            var field = fields[f];
            out[field] = this[field];
        }
        out.participants = {};
        for (var i in this.participants) {
            out.participants[i] = this.participants[i].shellAll();
        }
        out.apps = [];
        for (var i in this.apps) {
            out.apps[i] = this.apps[i].shellWithChildren();
        }
        out.clients = [];
        for (var i in this.clients) {
            out.clients[i] = this.clients[i].shell();
        }
        return out;
    }

    /**
    * shell - description
    *
    * @return {type}  description
    */
    shell() {
        var out = {};
        var fields = this.outputFields();
        for (var f in fields) {
            var field = fields[f];
            out[field] = this[field];
        }
        out.numParticipants = Utils.objLength(this.participants);
        out.numApps = Utils.objLength(this.apps);
        out.appSequence = [];
        for (var i in this.apps) {
            try {
                out.appSequence.push(this.apps[i].id);
            } catch (err) {}
        }
        out.clients = [];
        out.participants = [];
        return out;
    }

    /**
    * this - description
    *
    * @return {type}  description
    */
    outputFields() {
        var fields = [];
        for (var prop in this) {
            if (
                !Utils.isFunction(this[prop]) &&
                !this.outputHide.includes(prop)
            )
            fields.push(prop);
        }
        return fields;
    }

    /**
    * clientRemove - description
    *
    * @param  {type} socket description
    * @return {type}        description
    */
    clientRemove(socket) {
        var socketId = socket.id;
        this.jt.log('removing client: ' + socketId);
        for (var i=this.clients.length - 1; i>=0; i--) {
            var client = this.clients[i];
            if (client.id === socketId) {
                if (client.participant !== null) {
                    client.participant.clientRemove(client.id);
                }
                this.clients.splice(i, 1);
                this.jt.socketServer.sendOrQueueAdminMsg(null, 'remove-client', client.shell());
            }
        }
    }

    /**
    * participant - description
    *
    * @param  {type} participantId description
    * @return {type}               description
    */
    participant(participantId) {
        var participant = this.participants[participantId];
        if (participant == null && this.allowNewParts && this.isValidPId(participantId)) {
            participant = this.participantCreate(participantId);
            if (this.started) {
                participant.moveToNextStage();
            }
        }
        return participant;
    }

    /**
    * participantCreate - description
    *
    * @param  {type} pId description
    * @return {type}     description
    */
    participantCreate(pId) {
        if (!this.isValidPId(pId)) {
            return null;
        }

        var participantId = pId;
        this.jt.log('Session.participantCreate: ' + participantId);
        var participant = new Participant.new(participantId, this);
        participant.save();
        this.save();
        this.participants[participantId] = participant;
        if (this.jt.socketServer != null) {
            this.jt.socketServer.sendOrQueueAdminMsg(null, 'addParticipant', participant.shell());
        }
        return participant;
    }

    isValidPId(pId) {
        return (pId != null && this.allowNewParts) || this.potentialParticipantIds.includes(pId);
    }

    /**
    * Updates client with latest stage information.
    *
    * @param  {type} p description
    * @return {type}   description
    */
    playerRefresh(p) {
        this.io().to(p).emit('set-stage-name', this.curStage().name);
        this.curStage().onPlayerConnect(this.players[p]);
    }

    /**
    * participantMoveToNextApp - description
    *
    * CALLED FROM:
    * - {@link Participant#moveToNextStage}.
    *
    * @param  {type} participant description
    * @return {type}             description
    */
    participantMoveToNextApp(participant) {
        if (participant.getApp() != null) {
            participant.getApp().participantEnd(participant);
        }

        if (participant.appIndex < this.apps.length) {
            participant.appIndex++;
            participant.save();
            this.participantBeginApp(participant);
        } else {
            this.participantEnd(participant);
        }
        this.emitParticipantUpdates();
    }

    /**
    * Overwrite to add custom functionality.
    *
    * @param  {type} participant description
    * @return {type}             description
    */
    participantEnd(participant) {
    }

    /**
    * tryToEnd - description
    *
    * CALLED FROM:
    * - Participant.endSession()
    *
    * @return {type}  description
    */
    tryToEnd() {
        var proceed = true;
        var participants = this.participants;
        for (var p in participants) {
            var participant = participants[p];
            if (!participant.isFinishedSession()) {
                proceed = false;
                break;
            }
        }
        if (proceed) {
            this.end();
        }
    }

    end() {
        console.log('Session.end: ' + this.id);
    }

    csvFN() {
        return this.getOutputDir() + '/' + this.id + '.csv';
    }

    participantBeginApp(participant) {
        this.jt.log('Session.participantBeginApp: ' + participant.appIndex);

        if (participant.appIndex < 1 || participant.appIndex > this.apps.length) {
            console.log('Session.participantBeginApp: INVALID appIndex');
            return false;
        }

        var app = this.getApp(participant);

        // If the app has not yet been started, reload it first.
        if (!app.started) {
            let newApp = app.reload();
            this.apps[app.indexInSession() - 1] = newApp;
            app = newApp;
            app.start();
        }

        app.participantBegin(participant);
    }

    stageEndCheck(group) {
        this.jt.log("checking to end stage for group " + group.id);
        this.clockUpdate();
        if (this.timeLeft <= 0) {
            this.timeLeft = 0;
            this.clockStop();
            this.stageEnd(group);
        } else {
            this.jt.log('not ending stage');
            this.clockTimerStart();
        }
    }

    participantStart(participant) {
        
    }

    start() {
        if (!this.started) {
            this.started = true;
            for (let p in this.participants) {
                this.participantStart(this.participants[p]);
            }
            this.io().to(this.roomId()).emit('dataUpdate', [{
                roomId: this.roomId(),
                field: 'started',
                value: this.started
            }]);
            this.advanceSlowest();
        }
    }

    roomId() {
        return 'session_' + this.id;
    }

    io() {
        return this.jt.io;
    }

    /**
     * Return the next app in the session for this participant, null if there are no more apps for this participant.
     * @param {Participant} participant 
     */
    getApp(participant) {
        if (participant.appIndex < 1 || participant.appIndex > this.apps.length) {
            return null;
        } else {
            return this.apps[participant.appIndex - 1];
        }
    }

}

var exports = module.exports = {};
exports.new         = Session;
exports.load        = Session.load;