const Stage = require('./Stage.js');
const Period = require('./Period.js');
const Utils = require('./Utils.js');
const fs = require('fs-extra');
const path = require('path');
const Timer = require('./Timer.js');
/** Class that represents an app. */
class App {
/**
* Creates an App.
*
* @param {Session} session description
* @param {String} id description
*/
constructor(session, jt, appPath) {
/**
* The unique identifier of this App. In order of precedence, the value is given by:
* - the explicit value in the .jtt file (if it has been set)
* - the name of the .jtt file (if it is not app.jtt or app.js)
* - the name of the folder containing the .jtt file
* @type {String}
*/
this.id = appPath;
let id = appPath;
if (id.includes('app.js') || id.includes('app.jtt')) {
let str = null;
if (id.includes('app.js')) {
str = 'app.js';
} else if (id.includes('app.jtt')) {
str = 'app.jtt';
}
id = id.substring(0, id.lastIndexOf(str));
// Strip trailing slashes.
if (id.endsWith('/')) {
id = id.substring(0, id.lastIndexOf('/'));
} else if (id.endsWith('\\')) {
id = id.substring(0, id.lastIndexOf('\\'));
}
// Cut all but last part of path.
if (id.lastIndexOf('/') > -1) {
id = id.substring(id.lastIndexOf('/') + 1);
} else if (id.lastIndexOf('\\') > -1) {
id = id.substring(id.lastIndexOf('\\') + 1);
}
} else {
// Strip folders.
if (id.lastIndexOf('/') > -1) {
this.appDir = id.substring(0, id.lastIndexOf('/'));
id = id.substring(id.lastIndexOf('/') + 1);
} else if (id.lastIndexOf('\\') > -1) {
this.appDir = id.substring(0, id.lastIndexOf('\\'));
id = id.substring(id.lastIndexOf('\\') + 1);
}
this.appFilename = id;
if (id.endsWith('.js')) {
id = id.substring(0, id.length - '.js'.length);
} else if (id.endsWith('.jtt')) {
id = id.substring(0, id.length - '.jtt'.length);
}
}
this.shortId = id;
this.onSubmit = `
jt.popupMessage('Submitting...');
`;
/**
* @type {jt}
*/
this.jt = jt;
/** Where the original definition of this app is stored on the server.*/
this.appPath = appPath;
this.started = false;
this.outputDelimiter = ';';
if (session != null) {
this.outputDelimiter = session.outputDelimiter;
}
/**
* The Session that this App belongs to.
* @type {Session}
*/
this.session = session;
/**
*
*/
this.isStandaloneApp = true;
/**
* The stages of this app.
* @type Array
* @default []
*/
this.stages = [];
/**
* The options of this app.
* @type Array
* @default []
*/
this.options = [];
/**
* The option values of this app.
* @type Object
* @default {}
*/
this.optionValues = {};
// If not null, the length of time a participant has to play this app.
this.duration = null;
/** Used by the participant client to find and create dynamic text elements.*/
this.textMarkerBegin = '{{{';
this.textMarkerEnd = '}}}';
this.useVue = true;
/**
* The number of periods in this App.
* @type number
* @default 1
*/
this.numPeriods = 1;
/**
* Inserts jtree functionality to the start of client.html
* @type boolean
* @default true
*/
this.insertJtreeRefAtStartOfClientHTML = true;
/**
* Shown on all client screens.
* @type String
* @default
*/
this.html = `
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body class='hidden'>
<div id='jtree'>
<p v-show='app.numPeriods > 1'>{{ app.periodText }}: {{period.id}}/{{app.numPeriods}}</p>
<p v-show='hasTimeout && stage.showTimer'>Time left (s): {{clock.totalSeconds}}</p>
<span v-show='player.status=="playing"'>
{{stages}}
</span>
<span v-show='["ready", "waiting", "finished", "done"].includes(player.status)'>
{{waiting-screens}}
</span>
</div>
{{scripts}}
</body>
</html>
`;
this.periodText = 'Period'
this.vueModels = {};
// Objects defined here are generated on the client, and accessible via "jt.vue.XXX",
// where XXX is the name of the computed variable.
this.vueComputed = {};
this.vueMethods = {};
this.vueMethodsDefaults = {
checkForm: function (e) {
return true;
e.preventDefault();
}
}
this.clientScripts = null;
// Paths of script tags.
this.modifyPathsToIncludeId = true;
/** TODO: */
this.screen = '';
/** Shown on all client playing screens if {@link Stage.useAppActiveScreen} = true.
* @default null
*/
this.activeScreen = null;
/** Shown on all client waiting screens if {@link Stage.useWaitingScreen} = true.
*/
this.waitingScreen = `
<p>WAITING</p>
<p>The experiment will continue soon.</p>
`;
/** If 'htmlFile' is not null, content of 'htmlFile' is added to client content.
* Otherwise, if 'htmlFile' is null, content of this.id + ".html" is added to client content, if it exists.
*/
this.htmlFile = null;
/**
* The periods of this App.
* @type Array
* @default []
*/
this.periods = [];
/**
* Description of this App.
* @type String
* @default 'No description provided.'
*/
this.description = 'No description provided.';
/**
* Array of comparisons to show by default on Session -> Data tab.
* Each entry consists of:
* - field name (i.e. 'player.points')
* - x-axis object (i.e. 'player')
* - y-axis object (i.e. 'period')
* Field values are aggregated using the arithmetic mean as necessary (for example, if field is player.x, but x-axis is group, then table shows arithmetic mean of all player.x in a group).
* @type Array
* @default []
*/
this.keyComparisons = [];
/**
* Whether or not to wait for all participants before starting the app.
* @type boolean
* @default true
*/
this.waitForAll = true;
this.stageWaitToStart = true;
this.stageWaitToEnd = true;
/**
* The matching type to be used for groups in this App.
* @type string
* @default 'STRANGER'
*/
this.groupMatchingType = 'STRANGER';
/**
* Messages to listen for from clients.
* @type Object
* @default {}
*/
this.messages = {};
/**
* Set default value for Stage.wrapPlayingScreenInFormTag.
* If 'yes', then form is added.
* If 'no', no form is added.
* If 'onlyIfNoButton', then form is added only if page has no buttons.
* @type String
* @default true
*/
this.stageWrapPlayingScreenInFormTag = 'onlyIfNoButton';
/**
* If defined, subjects are assigned randomly to groups of this size takes precedence over numGroups.
* @type number
* @default undefined
*/
this.groupSize = undefined;
/**TODO:*/
this.groupingType = undefined;
/**
* Indicates whether or not the code for this app compiles to an error or not.
*/
this.hasError = false;
/**
* If not null and this is the first App in a Session, sets the initial number of players to this amount.
*/
this.suggestedNumPlayers = undefined;
/**
* if defined, subjects are split evenly into this number of groups
* overridden by groupSize.
* @type number
* @default undefined
*/
this.numGroups = undefined;
/**
* Starts the stages of this App.
* TODO:
* @type string
* @default '<span jt-stage="{{stage.id}}">'
*/
// this.stageContentStart = '<span jt-stage="{{stage.id}}">';
this.stageContentStart = `
<span v-show="stage.id == '{{stage.id}}'">
`;
/**
* Ends the stages of this App.
* TODO:
* @type string
* @default '</span>'
*/
this.stageContentEnd = '</span>';
//TODO:
this.outputHideAuto = [
'stageContentStart',
'stageContentEnd',
'optionValues',
'insertJtreeRefAtStartOfClientHTML',
'textMarkerBegin',
'textMarkerEnd',
'html',
'description',
'keyComparisons',
'screen',
'activeScreen',
'stageWrapPlayingScreenInFormTag',
'waitForAll',
'finished',
'htmlFile',
'this',
'session',
'stages',
'outputHideAuto',
'outputHide',
'periods',
'messages',
'folder',
'options',
'jt'
];
//TODO:
/**
* @type array
* @default []
*/
this.outputHide = [];
/** TODO: Description
* @type boolean
* @default false
*/
this.finished = false;
}
/**
* @static newSansId - return an app with the given path.
*
* @param {type} jt description
* @param {type} appPath The path relative to the server process. i.e. /apps/my-app.jtt or /apps/my-complex-app/app.js
* @return {App} The given app.
*/
static newSansId(jt, appPath) {
console.log('loading app with no session: ' + appPath);
var out = new App({}, jt, appPath);
return out;
}
/**
* @static Load an app from json.
*
* CALLED FROM:
* - {@link Session#load}
*
* @param {Object} json A json object containing the properties of the app.
* @param {Session} session The session this app belongs to.
* @return {App} A new app initialized with the data in json.
*/
static load(json, session) {
var index = json.sessionIndex;
var app = new App(session, json.id, session.jt);
// Run app code.
var folder = path.join(session.jt.path, session.getOutputDir() + '/' + index + '_' + json.id);
var appCode = Utils.readJS(folder + '/app.jtt');
eval(appCode);
//If there is already an app in place, save its stages and periods??
if (session.apps.length > index-1) {
var curApp = session.apps[index-1];
/** app.stages = curApp.stages;*/
app.periods = curApp.periods;
}
for (var j in json) {
app[j] = json[j];
}
session.apps[index-1] = app;
}
/**
* Returns the amount of time this participant has to play this app.
*/
getParticipantDuration(participant) {
return this.duration;
}
/**
* Overwrite to add custom functionality for when a {@link Client} starts this app.
*
* Called from:
* - {@link App#addClientDefault}.
*
* @param {Client} client The client who is connecting to this app.
*/
addClient(client) {
/** Overwrite */
}
/**
* Called when a {@link Client} connected to a {@link Participant} starts this app.
*
* - client subscribes to this App's channel.
* - client registers custom messages it may send.
* - client registers automatic stage submission messages it may send.
* - load custom behaviour [this.addClient]{@link App#addClient}.
*
* Called from:
* - {@link App#participantBegin}
*
* @param {Client} client The client who is connecting to this app.
*/
addClientDefault(client) {
client.socket.join(this.roomId());
for (var i in this.messages) {
var msg = this.messages[i];
client.register(i, msg);
}
// Register for automatic stage messages.
var app = this;
for (var s in this.stages) {
var stageName = this.stages[s].name;
// Listen to message from clients.
client.on(stageName, function(data) { // stage messages are sent by default when submit button is clicked.
app.session.pushMessage(client, data.data, data.data.fnName);
});
// Queue message.
client[stageName] = function(data) {
app.session.pushMessage(client, data, data.fnName + 'Process');
}
// Process the message.
client[stageName + 'Process'] = function(data) {
app.jt.log('Server received auto-stage submission: ' + JSON.stringify(data));
if (client.player() === null) {
return false;
}
if (client.player().roomId() !== data.playerRoomId || client.player().stage.id !== data.fnName) {
console.log('App.js, PLAYER ROOM ID DOES NOT MATCH, skipping submission: ' + client.player().stage.id + ' vs. ' + data.fnName + ', data=' + JSON.stringify(data));
return false;
}
// TODO: Not parsing strings properly.
for (var property in data) {
var value = data[property];
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
} else if (!isNaN(value)) {
value = parseFloat(value);
}
if (data.hasOwnProperty(property)) {
if (property.startsWith('player.')) {
var fieldName = property.substring('player.'.length);
client.player()[fieldName] = value;
} else if (property.startsWith('group.')) {
var fieldName = property.substring('group.'.length);
client.group()[fieldName] = value;
} else if (property.startsWith('participant.')) {
var fieldName = property.substring('participant.'.length);
client.participant[fieldName] = value;
} else if (property.startsWith('period.')) {
var fieldName = property.substring('period.'.length);
client.period()[fieldName] = value;
} else if (property.startsWith('app.')) {
var fieldName = property.substring('app.'.length);
client.app()[fieldName] = value;
}
}
}
var endForGroup = true;
client.player().endStage(endForGroup);
this.session.emitParticipantUpdates();
};
}
// Load custom code, overwrite default stage submission behavior.
try {
this.addClient(client);
} catch(err) {
console.log(err);
}
}
/** TODO */
addStages(array) {
for (var i=0; i<array.length; i++) {
this.addStage(array[i]);
}
}
/**
* Adds a stage, with contents loaded from .jtt file.
* @param {The name of the stage to add} name
*/
addStage(name) {
var stage = this.newStage(name);
var fn = path.join(path.dirname(this.id), name);
if (fs.existsSync(fn + '.jtt')) {
fn = fn + '.jtt';
} else if (fs.existsSync(fn + '.js')) {
fn = fn + '.js';
}
try {
eval(Utils.readJS(fn));
} catch (err) {
console.log('Error evaluating ' + fn);
console.log(err);
}
}
//TODO
setContents(contents) {
try {
fs.writeFileSync('apps/' + this.id + '.jtt', contents);
} catch (err) {
console.log(err);
}
}
//TODO
setFileContents(contents) {
try {
fs.writeFileSync(this.id, contents);
} catch (err) {
console.log(err);
}
}
/**
* Get next stage for player in their current period. Return null if already at last stage of period.
*
* DUE TO:
* {@link Stage.playerEnd}
*/
getNextStageForPlayer(player) {
var stageInd = player.stageIndex;
/** If not in the last stage, return next stage.*/
if (stageInd < this.stages.length-1) {
return this.stages[stageInd+1];
} else {
return null;
}
}
/**
* TODO
* Get group ids for their current period
*/
getGroupIdsForPeriod(period) {
var participants = this.session.participants;
var numGroups = period.numGroups();
var pIds = [];
for (var p in participants) {
pIds.push(p);
}
// Group IDs.
var gIds = [];
for (var g=0; g<numGroups; g++) {
gIds.push([]);
}
for (let i=0; i<period.groups.length; i++) {
let group = period.groups[i];
for (let j=0; j<group.players.length; j++) {
gIds[i].push(group.players[j].id);
for (let k=0; k<pIds.length; k++) {
if (pIds[k] == group.players[j].id) {
pIds.splice(k, 1);
}
}
}
}
// Calculate number of elements per group
var m = Math.floor((pIds.length-1) / numGroups) + 1;
if (this.groupMatchingType === 'PARTNER_1122') {
for (var g=this.groups.length; g<numGroups; g++) {
for (var i=0; i<m; i++) {
gIds[g].push(pIds[0]);
pIds.splice(0, 1);
}
}
} else if (this.groupMatchingType === 'PARTNER_1212') {
for (var i=0; i<m; i++) {
for (var g=this.groups.length; g<numGroups; g++) {
gIds[g].push(pIds[0]);
pIds.splice(0, 1);
}
}
} else if (this.groupMatchingType === 'PARTNER_RANDOM') {
if (period.id === 1) {
period.getStrangerMatching(numGroups, pIds, gIds, m, period.groups.length);
} else {
var prevPeriod = period.prevPeriod();
gIds = prevPeriod.groupIds();
}
} else if (this.groupMatchingType === 'STRANGER') {
period.getStrangerMatching(numGroups, pIds, gIds, m, period.groups.length);
}
return gIds;
}
sendParticipantPage(req, res, participant) {
// Load dynamic version of app to allow for live editing of stage html.
// var app = this;
var app = this.reload();
// Start with hard-coded html, if any.
var html = '';
if (app.html != null) {
html = html + app.html;
}
if (app.screen != null) {
html = html + app.screen;
}
// Load content of html file, if any.
// Try app.htmlFile, id.html, and client.html.
var htmlFile = app.htmlFile == null ? app.id + '.html' : app.htmlFile;
var filename = path.join(app.jt.path, '/apps/' + app.id + '/' + htmlFile);
if (fs.existsSync(filename)) {
html = html + Utils.readTextFile(filename);
} else {
htmlFile = 'client.html';
filename = path.join(app.jt.path, '/apps/' + app.id + '/' + htmlFile);
if (fs.existsSync(filename)) {
html = html + Utils.readTextFile(filename);
}
}
if (app.activeScreen != null) {
html += `
<span v-show='player.status == "playing"' class='playing-screen'>
${app.activeScreen}
<div>
{{stages}}
</div>
</span>
`;
}
if (!html.includes('{{stages}}')) {
html += `
<span v-show='player.status == "playing"' class='playing-screen'>
{{stages}}
</span>
`;
}
// Load stage contents, if any.
var stagesHTML = '';
var waitingScreensHTML = '';
for (var i=0; i<app.stages.length; i++) {
var stage = app.stages[i];
var stageHTML = '';
var contentStart = app.parseStageTag(stage, app.stageContentStart);
var contentEnd = app.parseStageTag(stage, app.stageContentEnd);
if (stage.content != null) {
stageHTML = contentStart + '\n' + stage.content + '\n' + contentEnd;
}
if (stage.activeScreen != null) {
stageHTML += app.parseStageTag(stage, app.stageContentStart) + '\n';
let wrapInForm = null;
if (stage.wrapPlayingScreenInFormTag === 'yes') {
wrapInForm = true;
} else if (stage.wrapPlayingScreenInFormTag === 'no') {
wrapInForm = false;
} else if (stage.wrapPlayingScreenInFormTag === 'onlyIfNoButton') {
if (
!stage.activeScreen.includes('<button') ||
stage.addOKButtonIfNone
) {
wrapInForm = true;
} else {
wrapInForm = false;
}
}
if (wrapInForm) {
stageHTML += '<form>\n';
}
stageHTML += stage.activeScreen + '\n';
if (stage.addOKButtonIfNone) {
if (!stageHTML.includes('<button')) {
stageHTML += `<button>OK</button>`;
}
}
if (wrapInForm) {
stageHTML += '</form>\n';
}
stageHTML += app.parseStageTag(stage, app.stageContentEnd);
}
if (stagesHTML.length > 0) {
stagesHTML += '\n';
}
stagesHTML += stageHTML;
var waitingScreenHTML = contentStart;
if (stage.useAppWaitingScreen) {
waitingScreenHTML += app.waitingScreen;
}
if (stage.waitingScreen != null) {
waitingScreenHTML += stage.waitingScreen;
}
waitingScreenHTML += contentEnd;
if (waitingScreensHTML.length > 0) {
waitingScreensHTML += '\n';
}
waitingScreensHTML += waitingScreenHTML;
}
let [strippedScripts, stagesHTML1] = this.stripTag('script', stagesHTML);
let [strippedStyles, stagesHTML2] = this.stripTag('style', stagesHTML1);
let [strippedLinks, stagesHTML3] = this.stripTag('link', stagesHTML2);
stagesHTML = stagesHTML3;
if (html.includes('{{stages}}')) {
html = html.replace('{{stages}}', stagesHTML);
}
// if (html.includes('{{waiting-screens}}') && app.waitingScreen != null) {
// html = html.replace('{{waiting-screens}}', app.waitingScreen);
// }
html = html.replace('{{waiting-screens}}', waitingScreensHTML);
// Replace {{ }} markers.
var markerStart = app.textMarkerBegin;
var markerEnd = app.textMarkerEnd;
while (html.indexOf(markerStart) > -1) {
var ind1 = html.indexOf(markerStart);
var ind2 = html.indexOf(markerEnd);
var text = html.substring(ind1+markerStart.length, ind2);
var span = '<i jt-text="' + text + '" style="font-style: normal"></i>';
html = html.replace(markerStart + text + markerEnd, span);
}
// Insert jtree functionality.
if (app.insertJtreeRefAtStartOfClientHTML) {
html = '<script type="text/javascript" src="/participant/jtree.js"></script>\n' + html;
}
let scriptsHTML = strippedLinks + '\n' + strippedStyles + '\n' + strippedScripts;
if (app.clientScripts != null) {
if (!app.clientScripts.trim().startsWith('<script')) {
scriptsHTML = '<script>' + app.clientScripts + '</script>';
} else {
scriptsHTML = app.clientScripts;
}
}
scriptsHTML += this.getAutoplayScript();
if (html.includes('{{scripts}}')) {
html = html.replace('{{scripts}}', scriptsHTML);
}
if (this.modifyPathsToIncludeId) {
let prefixes = ['href', 'src'];
for (let ind in prefixes) {
let i = prefixes[ind];
// Temporary fix, do not change anything that starts with '/' or 'http'.
html = html.replace(new RegExp(i + '="\\/', 'gmi'), i + 'XXX="');
html = html.replace(new RegExp(i + "='\\/", "gmi"), i + "XXX='");
html = html.replace(new RegExp(i + '="http', 'gmi'), i + 'XXXhttp="');
html = html.replace(new RegExp(i + "='http", "gmi"), i + "XXXhttp='");
// Replace all other paths.
html = html.replace(new RegExp(i + '="', 'gmi'), i + '="./' + this.shortId + '/');
html = html.replace(new RegExp(i + "='", "gmi"), i + "='./" + this.shortId + '/');
// Revert fix.
html = html.replace(new RegExp(i + 'XXX="', 'gmi'), i + '="/');
html = html.replace(new RegExp(i + "XXX='", "gmi"), i + "='/");
html = html.replace(new RegExp(i + 'XXXhttp="', 'gmi'), i + '="http');
html = html.replace(new RegExp(i + "XXXhttp='", "gmi"), i + "='http");
}
}
// Return to client.
res.send(html);
}
getAutoplayScript() {
let out = `
<script>
jt.autoplay = function() {
switch (jt.vue.player.stage.id) {
`;
for (let i=0; i<this.stages.length; i++) {
out += `
case "${this.stages[i].id}":
jt.autoplay_${this.stages[i].id}();
break;
`;
}
out += `
}
}
`
for (let i=0; i<this.stages.length; i++) {
out += `
if (jt.autoplay_${this.stages[i].id} == null) {
jt.autoplay_${this.stages[i].id} = function() {
${this.stages[i].autoplay}
};
}
`;
}
out += `
</script>`;
return out;
}
stripTag(tagName, text) {
let strippedText = '';
while (text.includes('<' + tagName)) {
let start = text.indexOf('<' + tagName);
if (start == -1) {
break;
}
let nextStart = text.indexOf('<' + tagName, start + tagName.length + 1);
let endTag = '/' + tagName + '>';
let end = text.indexOf(endTag, start) + endTag.length;
if (end == -1 || (nextStart > -1 && end > nextStart)) {
endTag = '>';
end = text.indexOf(endTag, start) + endTag.length;
}
if (end == -1 + endTag.length) {
break;
}
strippedText += text.substring(start, end);
text = text.substring(0, start) + text.substring(end);
}
return [strippedText, text];
}
// TODO
parseStageTag(stage, text) {
while (text.includes('{{')) {
var start = text.indexOf('{{');
var end = text.indexOf('}}');
var curTag = text.substring(start + '{{'.length, end);
var value = eval(curTag);
text = text.replace('{{' + curTag + '}}', value);
}
return text;
}
/**
* Ends this App.
*
* - Create headers for this app, periods, groups, group tables, players and participants.
* - Create output.
* - Write output to this session's csv file.
*
* CALLED FROM
* - {@link App#tryToEndApp}
*/
internalEnd() {
this.end();
let timeStamp = this.session.jt.settings.getConsoleTimeStamp();
console.log(timeStamp + ' END - APP : ' + this.getIdInSession());
this.finished = true;
let fd = fs.openSync(this.session.csvFN(), 'a');
try {
fs.appendFileSync(fd, this.saveOutput());
} catch(err) {
console.log('error writing app: ' + this.id);
debugger;
} finally {
fs.closeSync(fd);
}
}
end() {}
saveOutput() {
//Create headers
var appsHeaders = [];
var appsSkip = ['id'];
var appFields = this.outputFields();
Utils.getHeaders(appFields, appsSkip, appsHeaders);
var periodHeaders = [];
var groupHeaders = [];
var playerHeaders = [];
var periodSkip = ['id'];
var groupSkip = ['id', 'allPlayersCreated'];
var playerSkip = ['status', 'stageIndex', 'id', 'participantId'];
var groupTables = [];
var groupTableHeaders = {};
for (var i=0; i<this.periods.length; i++) {
var period = this.periods[i];
var periodFields = period.outputFields();
Utils.getHeaders(periodFields, periodSkip, periodHeaders);
for (var j=0; j<period.groups.length; j++) {
var group = period.groups[j];
for (var k=0; k<group.tables.length; k++) {
var tableId = group.tables[k];
if (!groupTables.includes(tableId)) {
groupTables.push(tableId);
groupTableHeaders[tableId] = [];
}
var tableHeaders = groupTableHeaders[tableId];
var tableFields = group[tableId].outputFields();
Utils.getHeaders(tableFields, [], tableHeaders);
}
var groupFields = group.outputFields();
Utils.getHeaders(groupFields, groupSkip, groupHeaders);
for (var k=0; k<group.players.length; k++) {
var player = group.players[k];
var fieldsToOutput = player.outputFields();
Utils.getHeaders(fieldsToOutput, playerSkip, playerHeaders);
}
}
}
var participantHeaders = [];
var participantSkip = ['id', 'points', 'periodIndex', 'appIndex'];
for (var i in this.session.participants) {
var participant = this.session.participants[i];
var participantFields = participant.outputFields();
Utils.getHeaders(participantFields, participantSkip, participantHeaders);
}
//Create data
var appsText = [];
appsText.push('id' + this.outputDelimiter + appsHeaders.join(this.outputDelimiter));
var newLine = this.id + this.outputDelimiter;
for (var h=0; h<appsHeaders.length; h++) {
var header = appsHeaders[h];
if (this[header] !== undefined) {
newLine += JSON.stringify(this[header]);
}
if (h<appsHeaders.length-1) {
newLine += this.outputDelimiter;
}
}
appsText.push(newLine);
var periodText = [];
var groupText = [];
var playerText = [];
groupText.push('period.id' + this.outputDelimiter + 'group.id' + this.outputDelimiter + groupHeaders.join(this.outputDelimiter));
playerText.push('period.id' + this.outputDelimiter + 'group.id' + this.outputDelimiter + 'participant.id' + this.outputDelimiter + playerHeaders.join(this.outputDelimiter));
for (var i=0; i<this.periods.length; i++) {
var period = this.periods[i];
var newLine = period.id + this.outputDelimiter;
newLine = this.appendValues(newLine, periodHeaders, period);
periodText.push(newLine);
for (var j=0; j<period.groups.length; j++) {
var group = period.groups[j];
var newLine = period.id + this.outputDelimiter + group.id + this.outputDelimiter;
newLine = this.appendValues(newLine, groupHeaders, group);
groupText.push(newLine);
for (var k=0; k<group.players.length; k++) {
var player = group.players[k];
var participant = player.participant;
var newLine = period.id + this.outputDelimiter + group.id + this.outputDelimiter + participant.id + this.outputDelimiter;
newLine = this.appendValues(newLine, playerHeaders, player);
playerText.push(newLine);
}
}
}
var participantText = [];
var participantHeadersText = 'id' + this.outputDelimiter + 'points';
if (participantHeaders.length > 0) {
participantHeadersText += this.outputDelimiter + participantHeaders.join(this.outputDelimiter);
}
participantText.push(participantHeadersText);
var pIds = Object.keys(this.session.participants);
Utils.alphanumSort(pIds);
for (var i in pIds) {
var participant = this.session.participants[pIds[i]];
var newLine = participant.id + this.outputDelimiter + participant.points();
if (participantHeaders.length > 0) {
newLine += this.outputDelimiter;
}
for (var h=0; h<participantHeaders.length; h++) {
var header = participantHeaders[h];
if (participant[header] !== undefined) {
newLine += JSON.stringify(participant[header]);
}
if (h<participantHeaders.length-1) {
newLine += this.outputDelimiter;
}
}
participantText.push(newLine);
}
// WRITE OUTPUT
let fullText = '';
fullText += 'APP ' + this.indexInSession() + '_' + this.id + '\n';
fullText += appsText.join('\n') + '\n';
if (periodHeaders.length > 0) {
fullText += 'PERIODS\n';
fullText += periodText.join('\n') + '\n';
}
if (groupHeaders.length > 0) {
fullText += 'GROUPS\n';
fullText += groupText.join('\n') + '\n';
}
for (var t=0; t<groupTables.length; t++) {
var groupTableText = [];
var table = groupTables[t];
var tableHeaders = groupTableHeaders[table];
groupTableText.push('period.id' + this.outputDelimiter + 'group.id' + this.outputDelimiter + 'id' + this.outputDelimiter + tableHeaders.join(this.outputDelimiter));
for (var i=0; i<this.periods.length; i++) {
var period = this.periods[i];
for (var j=0; j<period.groups.length; j++) {
var group = period.groups[j];
try {
var tabRows = group[table].rows;
for (var r=0; r<tabRows.length; r++) {
var row = tabRows[r];
var newLine = period.id + this.outputDelimiter + group.id + this.outputDelimiter + row.id + this.outputDelimiter;
newLine = this.appendValues(newLine, tableHeaders, row);
groupTableText.push(newLine);
}
} catch (err) {
}
}
}
fullText += groupTables[t].toUpperCase() + '\n';
fullText += groupTableText.join('\n') + '\n';
}
if (playerHeaders.length > 0) {
fullText += 'PLAYERS\n';
fullText += playerText.join('\n') + '\n';
}
fullText += 'PARTICIPANTS\n';
fullText += participantText.join('\n') + '\n';
return fullText;
}
/**
* @return {string} Session path + {@link App#indexInSession} + '_' + app.id
*/
getOutputFN() {
return this.session.getOutputDir() + '/' + this.getIdInSession();
}
/**
* - clear group's timer.
* - if next stage exists, let the group play it.
* - for each player in the group, call {@link App#playerMoveToNextStage}.
*
* @param {Group} group
*/
groupMoveToNextStage(group) {
group.clearStageTimer();
var nextStage = this.nextStageForGroup(group);
//If not at last stage of session, mvoe group to next stage.
if (nextStage !== null) {
nextStage.groupPlayDefault(group);
}
if (nextStage === null || nextStage.waitForGroup) {
for (var p in group.players) {
this.playerMoveToNextStage(group.players[p]);
}
}
}
getIdInSession() {
return this.indexInSession() + '_' + this.shortId;
}
/**
* @return {number} The index of this app in its session's list of apps (first position = 1).
*/
indexInSession() {
if (this.session === null) {
return -1;
}
for (var i in this.session.apps) {
if (this.session.apps[i] === this) {
return parseInt(i)+1;
}
}
return -1;
}
/**
* Creates a new period with the given id + 1, saves it and adds it to this App's periods.
*
* Called from:
* - {@link App.participantBeginPeriod}
*
* @param {number} prd The index to assign to the new period.
*/
initPeriod(prd) {
var period = new Period.new(prd + 1, this);
period.save();
this.periods.push(period);
}
/**
* metaData - description
*
* @return {type} description
*/
metaData() {
var metaData = {};
metaData.numPeriods = this.numPeriods;
metaData.groupSize = this.groupSize;
metaData.id = this.id;
metaData.shortId = this.shortId;
metaData.title = this.title;
metaData.description = this.description;
metaData.appPath = this.appPath;
metaData.hasError = this.hasError;
metaData.errorPosition = this.errorPosition;
metaData.errorLine = this.errorLine;
metaData.isStandaloneApp = this.isStandaloneApp;
// var folder = path.join(this.jt.path, this.jt.settings.appFolders[0] + '/' + this.id);
try {
if (this.appPath.endsWith('.jtt') || this.appPath.endsWith('.js')) {
metaData.appjs = Utils.readJS(this.appPath);
} else {
metaData.appjs = Utils.readJS(this.appPath + '/app.jtt');
}
} catch (err) {
metaData.appjs = '';
}
try {
metaData.clientHTML = Utils.readJS(this.appPath + '/client.html');
} catch (err) {
metaData.clientHTML = '';
}
var app = new App({}, this.jt, this.id);
metaData.stages = [];
try {
eval(metaData.appjs);
for (var i in app.stages) {
metaData.stages.push(app.stages[i].id);
}
metaData.numPeriods = app.numPeriods;
metaData.options = app.options;
} catch (err) {
metaData.stages.push('unknown');
metaData.numPeriods = 'unknown';
}
return metaData;
}
addPositiveIntegerOption(name, defaultVal, max, desc) {
this.addNumberOption(name, defaultVal, 1, max, 1, desc);
}
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]);
}
}
reload() {
var app = new App(this.session, this.jt, this.id);
app.optionValues = this.optionValues;
for (var opt in app.optionValues) {
app[opt] = app.optionValues[opt];
}
var appCode = Utils.readJS(this.appPath);
eval(appCode);
return app;
}
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;
}
}
/**
* Creates a new {@link Stage} and adds it to the App.
*
* @param {string} id The identifier of the stage.
* @return {Stage} The new stage.
*/
newStage(id) {
if (id == null) {
id = 'stage' + (this.stages.length + 1);
}
var stage = new Stage.new(id, this);
this.stages.push(stage);
return stage;
}
/**
* Returns the next stage for a given group in a given stage.
* 1. If stage is not the last stage of this app, return the next stage of this app.
* 2. If period is not the last period of this app, return the first stage of this app.
* 3. If app is not the last app of this session, return the first stage of the next app.
* 4. Otherwise, return null.
*
* TODO: Does the next stage have Stage.waitForGroup == true?
*
* CALLED FROM
* - {@link Stage#playerEnd}
* - {@link App#groupMoveToNextStage}
*
* @param {Group} group The group
* @return {(null|Stage)} The next stage for this group, or null.
*/
nextStageForGroup(group) {
var slowestPlayers = group.slowestPlayers();
return slowestPlayers[0].nextStage();
}
/**
* Called when a player finishes a stage.
* By default, check whether everyone in group is finished.
* If yes, then advance to next stage ([this.session.gotoNextStage(player.group)]{@link Session.gotoNextStage}).
*
* @param {Player} player description
*/
onPlayerFinished(player) {
var proceed = true;
for (var p in player.group.players) {
var pId = player.group.players[p];
if (this.session.player(pId).status !== 'finished') {
proceed = false;
break;
}
}
if (proceed) {
this.session.gotoNextStage(player.group);
}
}
/**
* The names of fields to include in an export of this object. To be included, a field must:
* - not be a function ({@link Utils.isFunction})
* - not be included in {@link App#outputHide}
* - not be included in {@link App#outputHideAuto}
*
* @return {Array} An array of the field names.
*/
outputFields() {
var fields = [];
for (var prop in this) {
if (
App.prototype[prop] !== this[prop] &&
!this.outputHide.includes(prop) &&
!this.outputHideAuto.includes(prop)
)
fields.push(prop);
}
return fields;
}
start() {
if (this.started) {
return;
}
this.started = true;
}
/**
* Called when a participant begins this app.
* - For each of the [Clients]{@link Client} of this participant, call {@link App.addClientDefault}.
* - Set the participant's periodIndex to -1.
* - Participant notifies clients about appIndex.
* - Move participant to next period {@link App.participantMoveToNextPeriod}.
* - Participant notified clients about starting new app.
*
* @param {Participant} participant The participant.
*/
participantBegin(participant) {
for (var c in participant.clients) {
var client = participant.clients[c];
this.addClientDefault(client);
}
participant.periodIndex = -1;
participant.emit('participantSetAppIndex', {appIndex: this.indexInSession()});
let duration = this.getParticipantDuration(participant);
if (duration != null) {
participant.appTimer = new Timer.new(
function() {
participant.session.addMessageToStartOfQueue(participant, {}, 'endCurrentApp');
},
duration*1000
);
}
this.participantStart(participant);
this.participantMoveToNextPeriod(participant);
participant.emit('start-new-app'); /** refresh clients.*/
}
/**
* A participant begins its current period.
*
* - Participant notifies its clients of periodIndex.
* - If current period undefined, initialize it ({@link App.initPeriod}).
* - Participant begins period ({@link Period.participantBegin}).
*
* Called from:
* - {@link App.participantMoveToNextPeriod}.
*
* @param {type} participant The participant.
*/
participantBeginPeriod(participant) {
var prd = participant.periodIndex;
participant.emit('participantSetPeriodIndex', {periodIndex: participant.periodIndex});
var period = this.getPeriod(prd);
if (period === undefined) {
return false;
}
period.participantBegin(participant);
}
getPeriod(index) {
if (this.periods[index] == undefined) {
this.initPeriod(index);
}
return this.periods[index];
}
/**
* A participant moves to its next period.
*
* FUNCTIONALITY
* - If participant is currently in a period, end it ({@link Period.participantEnd}).
* - Increment participant's period index.
* - Save participant ({@link Participant.save}).
* - If participant is has finished all periods in this app, move to next app ({@link Session.participantMoveToNextApp}).
* - Otherwise, begin new period ({@link App.participantBeginPeriod}).
*
* CALLED FROM
* - {@link App#playerMoveToNextStage}
* - {@link App#participantBegin}
*
* @param {type} participant description
* @return {type} description
*/
participantMoveToNextPeriod(participant) {
// If in the last period of app, move to next app.
if (participant.periodIndex >= this.numPeriods - 1) {
this.session.participantMoveToNextApp(participant);
}
// Move to the next period of this app.
else {
// If not in the first period, end the previous period for this participant.
if (participant.periodIndex > -1) {
participant.player.period().participantEnd(participant);
}
// Move to next period.
participant.periodIndex++;
participant.save();
this.participantBeginPeriod(participant);
}
}
/**
* A participant finishes playing this app.
*
* FUNCTIONALITY
* - Participant's clients unsubscribe from messages from this app.
* - Try to end the app ({@link App#tryToEndApp}).
*
* CALLED FROM
* - xxx
*
* @param {Participant} participant The participant.
*/
participantEndInternal(participant) {
// for (var c in participant.clients) {
// var client = participant.clients[c];
// client.socket.leave(this.roomId());
// }
this.participantEnd(participant);
this.tryToEndApp();
}
participantEnd(participant) {}
// /**
// * Move the player to their next stage.
// *
// * FUNCTIONALITY
// * - if player has not finished all stages,
// * -- set player status to 'playing'
// * -- increment player's stage index.
// * -- play next stage ({@link Stage#playerPlayDefault}).
// * -- player emits update ({@link Player#emitUpdate2}).
// * - otherwise, move participant to next period ([this.participantMoveToNextPeriod(player.participant)]{@link App#participantMoveToNextPeriod}).
// *
// * CALLED FROM
// * - {@link App#groupMoveToNextStage}
// *
// * @param {Player} player The player
// * @return {type} description
// */
// playerMoveToNextStage(player) {
// if (player.stageIndex < this.stages.length - 1) {
// player.status = 'playing';
// player.stageIndex++;
// player.stage = this.stages[player.stageIndex];
// player.stage.playerPlayDefault(player);
// player.emitUpdate2();
// } else {
// this.participantMoveToNextPeriod(player.participant);
// }
// }
/**
* Returns the player of the current player's participant from the previous period.
*
* @param {Player} player The player.
* @return {Player} The previous player, if any.
*/
previousPlayer(player) {
var prevPeriod = player.period().prevPeriod();
if (prevPeriod === null) {
return null;
} else {
return prevPeriod.playerByParticipantId(player.id);
}
}
/**
* Returns the group of the current player's participant from the previous period.
*
* @param {Group} group The group.
* @return TODO:{Player} The previous player, if any.
*/
previousGroup(group) {
var prevPeriod = group.period().prevPeriod();
if (prevPeriod === null) {
return null;
} else {
return Utils.findByIdWOJQ(prevPeriod.groups, group.id);
}
}
/**
* roomId - description
*
* @return {string} {@link Session#roomId} + '_app_' + this.id
*/
roomId() {
return this.session.roomId() + '_app_' + this.indexInSession() + '-' + this.id;
}
/**
* saveSelfAndChildren - description
*
* CALLED FROM:
* - {@link Session#addApp}
* @return {type} description
*/
saveSelfAndChildren() {
this.save();
for (var i in this.stages) {
this.stages[i].save();
}
}
/**
* Save this {@link App} to the session .gsf file.
*
* CALLED FROM:
* - {@link App#saveSelfAndChildren}
*
*/
save() {
try {
this.session.jt.log('App.save: ' + this.id);
var toSave = this.shell();
this.session.saveDataFS(toSave, 'APP');
} catch (err) {
console.log('Error saving app ' + this.id + ': ' + err);
}
}
copyFieldsTo(obj) {
var fields = this.outputFields();
for (var f in fields) {
var field = fields[f];
if (Utils.isFunction(this[field])) {
obj['__func_' + field] = this[field].toString();
} else {
obj[field] = this[field];
}
}
}
/**
* A shell of this object. Excludes parent, includes child shells.
*
* CALLED FROM:
* - {@link Session#addApp}
*
* @return {type} description
*/
shellWithChildren() {
var out = {};
this.copyFieldsTo(out);
out.indexInSession = this.indexInSession();
out.periods = [];
for (var i in this.periods) {
out.periods[i] = this.periods[i].shellWithChildren();
}
out.stages = [];
for (var i in this.stages) {
out.stages[i] = this.stages[i].shell();
}
out.options = this.options;
return out;
}
/**
* A shell of this object. Includes parent shell, excludes child shells.
*
* @return {type} description
*/
shellWithParent() {
var out = {};
this.copyFieldsTo(out);
out.session = this.session.shell();
out.numStages = this.stages.length;
out.vueComputedText = {};
for (let i in this.vueComputed) {
out.vueComputedText[i] = this.vueComputed[i].toString();
}
out.vueMethodsText = {};
for (let i in this.vueMethods) {
out.vueMethodsText[i] = this.vueMethods[i].toString();
}
for (let i in this.vueMethodsDefault) {
if (out.vueMethodsText[i] == null) {
out.vueMethodsText[i] = this.vueMethodsDefault[i].toString();
}
}
return out;
}
/**
* A shell of this object. Excludes parent and children. The shell is a simplified version of an object and any of its fields.
*
* CALLED FROM
* - {@link App#save}
*
* @return {Object} description
*/
shell() {
var out = {};
this.copyFieldsTo(out);
out.sessionIndex = this.indexInSession();
return out;
}
/**
* If all participants have finished the app, end the app ({@link App#end}).
*
* CALLED FROM
* - {@link App#participantEndInternal}
*
* @return {type} description
*/
tryToEndApp() {
if (this.finished) {
return;
}
var proceed = true;
var participants = this.session.participants;
for (var p in participants) {
var participant = participants[p];
if (!participant.isFinishedApp(this)) {
proceed = false;
break;
}
}
if (proceed) {
this.internalEnd();
}
}
// Helper method for writing to csv.
//TODO:
appendValues(newLine, headers, obj) {
for (var i=0; i<headers.length; i++) {
var header = headers[i];
var value = obj[header];
if (value !== undefined && value !== null) {
// If value is an object, change it to string.
if (typeof value === 'object') {
value = JSON.stringify(obj[header]);
}
// If value is a string, replace any commas.
if (typeof value === 'string') {
value = value.replace(/,/g, '--');
}
// Append the value.
newLine += value;
}
if (i<headers.length-1) {
newLine += this.outputDelimiter;
}
}
return newLine;
}
/**
* Overwrite in app.jtt.
*
* @param {type} participant description
* @return {type} description
*/
participantStart(participant) {}
getNextPeriod(participant) {
if (participant.player != null) {
participant.periodIndex = participant.player.group.period.id - 1;
}
if (participant.periodIndex >= this.numPeriods - 1) {
return null;
} else {
return this.getPeriod(participant.periodIndex+1);
}
}
}
var exports = module.exports = {};
exports.new = App;
exports.load = App.load;
exports.newSansId = App.newSansId;