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 { MODES }     = require('./core/Settings.js');
const { dialectFor } = require('./dialects/index.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;

        const defaults = jt.settings.session || {};

        /**
        * Money every participant is paid for taking part (z-Tree's ShowUpFee, oTree's
        * participation_fee). Default from settings.json's session.showUpFee.
        * @type number
        */
        this.showUpFee = defaults.showUpFee != null ? defaults.showUpFee : 0;

        /**
        * Money per point, for apps that do not set app.exchangeRate. Default from settings.json's
        * session.exchangeRate.
        * @type number
        */
        this.exchangeRate = defaults.exchangeRate != null ? defaults.exchangeRate : 1;

        /**
        * The currency payments are in, e.g. 'EUR', for showing them.
        * @type string
        */
        this.currency = defaults.currency != null ? defaults.currency : '';

        /**
        * 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;

        /**
         * Whose rules apply where the engines differ, for this session: 'jtree', 'ztree' or
         * 'otree'; null for the server's (settings.mode). See {@link Session#engineMode}.
         */
        this.mode = null;

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

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

        /** The queues being added, innermost last, see {@link Session#addQueue}. */
        this.addingQueues = [];

        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',
            'addingQueues',
            'botsPlaying',
        ];

        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.
    */
    /**
     * One line of a session's .gsf file (see {@link Session#saveDataFS}). Files written
     * by jtree 0.9.0 and earlier separate the type from the rest with the CSV delimiter (';' by default),
     * which is not JSON; read those too.
     */
    static parseDataLine(line) {
        try {
            return JSON.parse(line);
        } catch (err) {
            return JSON.parse(line.replace(/^(\{"type":"[A-Z_]+")[^,"]/, '$1,'));
        }
    }

    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 = Session.parseDataLine(lines[i]);
                    switch (json.type) {
                        case 'SESSION':
                        // The session's fields as of this record; its participants and apps
                        // come from their own records.
                        Utils.copySavedFields(session, json, ['participants', 'apps', 'clients', 'modeInForce', 'serverMode']);
                        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 'DELETE_PARTICIPANT':
                        delete session.participants[json.id];
                        break;
                        case 'TABLE':
                        Table.load(json, session);
                        break;
                    };
                }
            } catch (err) {
//                console.log(err.stack);
            }
        }

        // Link participants and players: each participant's player is their last in the
        // session's apps, and then in the apps within its stage, if it has stages (see
        // Participant#restoreRuns).
        for (var a in session.apps) {
            var app = session.apps[a];
            for (const linked of [app].concat(app.runs())) {
                for (var prd in linked.periods) {
                    var period = linked.periods[prd];
                    for (var gr in period.groups) {
                        var group = period.groups[gr];
                        group.restoreStageTimer(data.lastTimeOn);
                        for (var pl in group.players) {
                            var player = group.players[pl];
                            var participant = session.participants[player.participantId];
                            player.participant = participant;
                            if (linked === app) {
                                participant.player = player;
                            }
                            participant.players.push(player);
                            if (player.stageIndex > -1) {
                                player.stage = player.app().stages[player.stageIndex];
                            }
                        }
                    }
                }
            }
        }
        // Who has left the apps within stages: those past the stage.
        for (const run of session.allApps()) {
            if (run.parentPeriod == null) {
                continue;
            }
            const index = run.parentStage.indexInApp();
            for (const group of run.parentPeriod.groups) {
                for (const player of group.players) {
                    if (player.stageIndex > index || (player.stageIndex === index && ['done', 'finished'].includes(player.status))) {
                        run.leftIds.push(player.participantId);
                    }
                }
            }
        }
        for (const participant of Object.values(session.participants)) {
            participant.restoreRuns();
        }

        return session;
    }

    /** The session's apps, each followed by the apps within its stages (see App#runs). */
    allApps() {
        var out = [];
        for (const app of this.apps) {
            out.push(app, ...app.runs());
        }
        return out;
    }

    /**
     * The app at address (see App#address): one of the session's apps, or an app within one
     * of their stages, made if it is not there yet (as when a session is loaded).
     * @param {number[]} address
     * @return {App}
     */
    appAt(address) {
        var app = this.apps[address[0] - 1];
        for (let i = 1; i < address.length; i += 2) {
            const periodId = address[i];
            if (app.periods[periodId - 1] == null) {
                app.periods[periodId - 1] = new Period.new(periodId, app);
            }
            app = app.stages[address[i + 1]].runFor(app.periods[periodId - 1]);
        }
        return app;
    }

    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);
            }
        }
    }

    /**
     * Copies the files of the apps added as stages of app (see App#addApp) to the session's
     * copy of app, where they are in relation to app's file, so that the copy loads them
     * (see App.load). Those outside the session's folder are not copied.
     * @param {App} app
     */
    copyAppFiles(app) {
        for (const file of app.appFiles()) {
            const folderApp = ['app.jtt', 'app.js'].includes(path.basename(file));
            const source = folderApp ? path.dirname(file) : file;
            const dest = path.resolve(app.getOutputFN(), path.relative(app.appDir, source));
            if (!dest.startsWith(this.getOutputDir() + path.sep)) {
                console.log('Not copying ' + source + ' to the session: it is outside the app\'s folder.');
                continue;
            }
            if (folderApp) {
                Utils.copyFiles(source, dest);
            } else {
                Utils.copyFile(path.basename(source), path.dirname(source), path.dirname(dest));
            }
        }
    }

    /**
    * 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) {

        // Inside a queue, paths are relative to the queue's file.
        let queue = this.addingQueues[this.addingQueues.length - 1];
        if (queue != null) {
            appPath = queue.resolve(appPath);
        }

        if (require('./Queue.js').isQueueId(appPath)) {
            return this.addQueue(appPath, options);
        }

        try {
            var app = this.jt.data.loadApp(appPath, this, appPath, options);
            if (app !== null) {
                this.apps.push(app);
                if (app.isFolderApp()) {
                    // With its stages' and client files.
                    Utils.copyFiles(app.appDir, app.getOutputFN());
                } else if (dialectFor(app.appPath) != null) {
                    Utils.copyFile(app.appFilename, app.appDir, app.getOutputFN());
                } else {
                    Utils.copyFiles(path.parse(app.appPath).dir, app.getOutputFN());
                }
                this.copyAppFiles(app);
                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});
    }

    /**
    * Add the apps of the queue at queuePath to this session, by running its script against this
    * session. A queue's options are session options, so they are set on this session first.
    * Called from {@link Session#addApp}.
    */
    addQueue(queuePath, options) {
        const Queue = require('./Queue.js');
        var queue = Queue.load(queuePath, this.jt);
        if (queue.hasError) {
            return null;
        }
        for (let name in options) {
            this[name] = options[name];
        }
        this.addingQueues.push(queue);
        try {
            queue.run(this);
        } catch (err) {
            this.jt.log('Error running queue ' + queuePath + ': ' + err);
        } finally {
            this.addingQueues.pop();
        }
        return queue;
    }

    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 path.join(this.jt.path, 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 {

            // Stage submissions are converted by forms.js, by the fields' types.
            if (!['endStage', 'endApp', 'forceEndStage'].includes(fn) && !(obj[fn] && obj[fn].convertsOwnValues)) {
                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);
        }
        // Save the change, and the time left on groups' stage timers (see Group.load).
        this.saveDataFS(this.shell(), 'SESSION');
        for (const app of this.allApps()) {
            for (const period of app.periods) {
                for (const group of period.groups) {
                    if (group.stageTimer !== undefined) {
                        group.save();
                    }
                }
            }
        }
        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 (const app of this.allApps()) {
            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);
                    }
                    if (group.stageGraceTimer !== undefined) {
                        out.push(group.stageGraceTimer);
                    }
                    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().hostApp();
            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;
                }
            }
        }
        // More than the list has (settings.participantIds: P1 to P99): P100, P101, ...
        for (let n = this.potentialParticipantIds.length + 1; partsAdded < num; n++) {
            const pId = 'P' + n;
            if (this.participants[pId] === undefined) {
                this.participantCreate(pId);
                partsAdded++;
            }
        }
    }

    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].outputWithRuns();
            }
            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);
    }

    /**
     * Plays the session to its end with bots (admin v2's "Play with bots"): oTree apps by their
     * own bots (tests.py), and the generic bot where an app has none; z-Tree treatments by the
     * generic z-Tree bot. Starts the session if it is not. Tells admins how it goes
     * ('sessionBots': {sId, state: 'running' | 'done' | 'failed', message}). Resolves once done.
     */
    async playWithBots({ timeout = 10 * 60 * 1000 } = {}) {
        if (this.botsPlaying) return;
        const say = (state, message) => {
            this.bots = { state, message };
            this.emit('sessionBots', { sId: this.id, state, message });
        };
        const otree = this.apps.length > 0 && this.apps.every((a) => a.otree != null);
        const ztree = this.apps.length > 0 && this.apps.every((a) => a.ztree != null);
        if (!otree && !ztree) {
            say('failed', 'Bots play sessions of oTree apps or of z-Tree treatments. For jtree apps, use Autoplay.');
            return;
        }
        if (Object.keys(this.participants).length === 0) {
            say('failed', 'The session has no participants.');
            return;
        }
        this.botsPlaying = true;
        say('running', otree ? "oTree's bots are playing (the generic bot where an app has none)." : 'The generic z-Tree bot is playing.');
        try {
            if (!this.started) this.start();
            if (otree) {
                await require('./dialects/otree/bots.js').runBots(this, { timeout, generic: 'missing' });
            } else {
                await require('./dialects/ztree/bots.js').runZtreeBots(this, { timeout });
            }
            say('done', 'The bots played the session to its end.');
        } catch (err) {
            say('failed', String(err && err.message || err));
        } finally {
            this.botsPlaying = false;
        }
    }

    /** Whose rules apply where z-Tree, oTree and jtree differ: the session's mode, else the server's. */
    engineMode() {
        return this.mode || this.jt.settings.mode || 'jtree';
    }

    /** Sets the session's mode ('jtree', 'ztree', 'otree'), or null for the server's. */
    setMode(mode) {
        if (mode != null && mode !== '' && !MODES.includes(mode)) throw new Error('unknown mode ' + mode);
        this.mode = mode || null;
        this.save();
        this.emit('setSessionMode', { sId: this.id, value: this.mode, mode: this.engineMode() });
    }

    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];
        // So that loading the session does not bring the participant back (see Session.load).
        this.saveDataFS({id: pId}, 'DELETE_PARTICIPANT');
        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) {
        // Closed when jtree stops (see Data#stop).
        if (this.fileStream.writableEnded) {
            return;
        }
        try {
            // One JSON object per line, with its type first: {"type":"APP","id":...}.
            var a = JSON.stringify(d);
            var output = '{"type":"' + type + '"' + (a === '{}' ? '' : ',') + a.slice(1) + '\n';
            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();
        }
        // The formats its data can be downloaded in (see exporters).
        out.exports = require('./exporters/index.js').exportersFor(this).map((e) => ({ id: e.id, name: e.name }));
        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 = [];
        // Whose rules it runs by (its mode, else the server's; see engineMode).
        out.modeInForce = this.engineMode();
        out.serverMode = this.jt.settings.mode;
        // The formats its data can be downloaded in (see exporters).
        out.exports = require('./exporters/index.js').exportersFor(this).map((e) => ({ id: e.id, name: e.name }));
        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) {
        // An app within a stage (see Stage#runFor): back to the app it is in.
        if (participant.frames.length > 0) {
            participant.exitRun();
            this.emitParticipantUpdates();
            return;
        }
        const app = participant.getApp();
        if (app != null) {
            participant.finishedApps.push(app.getIdInSession());
            // Calls the app's participantEnd, and ends the app if everyone has finished it.
            app.participantEndInternal(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) {
        
    }

    /**
     * The participant with label (e.g. a room participant's, see Room#sendToSession): the one
     * with it already, or the first without one, which gets it; null if none is left.
     */
    participantForLabel(label) {
        const participants = Object.values(this.participants);
        let participant = participants.find((p) => p.label === label);
        if (participant == null) {
            participant = participants.find((p) => p.label == null);
            if (participant != null) {
                participant.label = label;
                participant.save();
            }
        }
        return participant || null;
    }

    /** The app, period, group or player of this session whose roomId() is id; or null. */
    objectByRoomId(id) {
        for (const app of this.apps) {
            if (app.roomId() === id) return app;
            for (const period of app.periods) {
                if (period == null) continue;
                if (period.roomId() === id) return period;
                for (const group of period.groups) {
                    if (group.roomId() === id) return group;
                    for (const player of group.players) {
                        if (player.roomId() === id) return player;
                    }
                }
            }
        }
        return null;
    }

    /** What each participant is paid (see Participant#payment), by participant id. */
    payments() {
        const out = [];
        for (const participant of Object.values(this.participants)) {
            out.push({ id: participant.id, points: participant.points(), payment: participant.payment() });
        }
        return out;
    }

    start() {
        if (!this.started) {
            this.started = true;
            // Saved, so that a loaded session is not started again.
            this.saveDataFS(this.shell(), 'SESSION');
            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;