const vm = require('vm');
const Utils = require('../Utils.js');
/*
* The app description (IR): a plain, JSON-serialisable description of an app, which every
* dialect's importer produces and from which jtree builds an App (applyIR). jtree apps can
* be described as one too (appToIR). See plans/ztree-otree-dialects.md ยง2.1.
*
* @typedef {Object} AppIR
* @property {1} ir Version of this format.
* @property {string} [dialect] Where the app came from: 'jtree', 'ztree', 'otree', ...
* @property {string} [title]
* @property {string} [description]
* @property {number} [numPeriods]
* @property {number} [groupSize]
* @property {number} [numGroups]
* @property {string} [groupMatchingType] 'STRANGER', 'PARTNER_1122', ... (see App#getGroupIdsForPeriod)
* @property {boolean} [groupByArrival] Group participants in the order they arrive instead.
* @property {number} [exchangeRate] Money per point in this app; the session's if not set.
* @property {number} [suggestedNumPlayers]
* @property {OptionIR[]} [options] Treatment options, set when the app is added to a session.
* @property {Object<string, *>} [values] The app's own fields, e.g. {endowment: 20}: app.endowment.
* @property {Object<string, Program>} [functions] The app's own methods, e.g. app.payoff(player).
* @property {Object<string, Program>} [messages] Messages from participants' pages: app.messages.
* @property {Object<string, FieldIR>} [fields] The fields forms submit, by name ('player.x'): see forms.js.
* @property {Object<string, Program>} [hooks] The app's lifecycle: appStart(), periodStart(period),
* periodEnd(period), appEnd() (app.end), participantStart(participant),
* participantEnd(participant).
* @property {ScreenIR} [screen] Screens shared by every stage.
* @property {StageIR[]} stages
*
* @typedef {Object} OptionIR
* @property {string} name
* @property {'number'|'text'|'select'} type
* @property {*} [default] For 'select', the first of values.
* @property {number} [min]
* @property {number} [max]
* @property {number} [step]
* @property {Array} [values] For 'select'.
* @property {string} [description]
*
* @typedef {Object} FieldIR
* @property {'int'|'number'|'string'|'bool'|'choice'} type
* @property {number|Program} [min] Or (player) => min.
* @property {number|Program} [max]
* @property {Array|Program} [choices] For 'choice': values, or [value, label] pairs.
* @property {boolean} [blank] May be left empty.
* @property {string} [label]
*
* @typedef {Object} StageIR
* @property {string} id
* @property {number|Program} [duration] Seconds for each group, from when it starts the stage; or
* (group) => seconds.
* @property {number|Program} [playerDuration] Seconds for each player, from when they start it;
* or (player) => seconds.
* @property {boolean} [endOnTimeout] End the stage when the time is up (true), or only mark
* the players timed out (false).
* @property {boolean} [waitToStart] Wait for the whole group before starting.
* @property {boolean} [waitToEnd] Wait for the whole group before ending.
* @property {boolean} [waitForAllGroups] Wait for every group of the period before starting.
* @property {boolean} [waitOnTimerEnd] On timeout, ask pages to submit (true) or end at once.
* @property {number|null} [timeoutGrace] Seconds pages get to submit on a timeout.
* @property {Program} [participate] (player) => whether the player plays this stage.
* @property {Object<string, Program>} [programs] groupStart, playerStart, groupEnd, playerEnd,
* allGroupsStart (with waitForAllGroups), validate(player, values).
* @property {string[]} [formFields] The fields the stage's form must send.
* @property {ScreenIR} [screen]
* @property {Object<string, *>} [values] The stage's other fields, e.g. {updateObject: 'group'}.
*
* @typedef {Object} ScreenIR
* @property {string} [renderer] How pages draw it; 'vue' (the default) is jtree's.
* @property {string} [active] What a player playing the stage sees.
* @property {string} [waiting] What a player waiting sees.
* @property {Object<string, Program>} [computed] For 'vue': computed values (app.vueComputed).
* @property {Object<string, Program>} [methods] For 'vue': methods (app.vueMethods).
*
* A program: code in some language. For 'js', `source` is a function expression; it runs
* with `app` and `Utils` in scope, and `this` as jtree calls it.
* @typedef {Object} Program
* @property {string} lang
* @property {string} source
*/
const APP_KEYS = ['ir', 'dialect', 'title', 'description', 'numPeriods', 'groupSize', 'numGroups',
'groupMatchingType', 'groupByArrival', 'exchangeRate', 'suggestedNumPlayers', 'options', 'values', 'functions', 'messages', 'hooks',
'fields', 'screen', 'stages'];
const FIELD_TYPES = ['int', 'number', 'string', 'bool', 'choice'];
// The app's lifecycle hooks, by their name in an IR, and the App method each is.
const APP_HOOKS = { appStart: 'appStart', periodStart: 'periodStart', periodEnd: 'periodEnd', appEnd: 'end',
participantStart: 'participantStart', participantEnd: 'participantEnd' };
const STAGE_KEYS = ['id', 'formFields', 'duration', 'playerDuration', 'endOnTimeout', 'waitToStart', 'waitToEnd', 'waitForAllGroups', 'waitOnTimerEnd', 'timeoutGrace',
'participate', 'programs', 'screen', 'values'];
const STAGE_PROGRAMS = ['groupStart', 'playerStart', 'groupEnd', 'playerEnd', 'allGroupsStart', 'validate'];
const SCREEN_KEYS = ['renderer', 'active', 'waiting', 'computed', 'methods'];
const OPTION_TYPES = ['number', 'text', 'select'];
/** The problems with ir, as messages naming where they are; empty if there are none. */
function validate(ir) {
const problems = [];
const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
const keys = (obj, allowed, where) => {
for (const k of Object.keys(obj)) {
if (!allowed.includes(k)) problems.push(where + ': unknown field "' + k + '"');
}
};
const type = (v, t, where) => {
if (v !== undefined && typeof v !== t) problems.push(where + ': should be a ' + t);
};
const program = (p, where) => {
if (!isObject(p) || typeof p.lang !== 'string' || typeof p.source !== 'string') {
problems.push(where + ': should be a program, {lang, source}');
}
};
const programs = (obj, where, names) => {
if (obj === undefined) return;
if (!isObject(obj)) return problems.push(where + ': should be an object');
for (const [k, p] of Object.entries(obj)) {
if (names && !names.includes(k)) problems.push(where + ': unknown program "' + k + '"');
program(p, where + '.' + k);
}
};
const screen = (s, where) => {
if (s === undefined) return;
if (!isObject(s)) return problems.push(where + ': should be an object');
keys(s, SCREEN_KEYS, where);
for (const k of ['renderer', 'active', 'waiting']) type(s[k], 'string', where + '.' + k);
programs(s.computed, where + '.computed');
programs(s.methods, where + '.methods');
};
if (!isObject(ir)) return ['the app description should be an object'];
keys(ir, APP_KEYS, 'app');
if (ir.ir !== 1) problems.push('app.ir: should be 1, the version of this format');
for (const k of ['dialect', 'title', 'description', 'groupMatchingType']) type(ir[k], 'string', 'app.' + k);
for (const k of ['numPeriods', 'groupSize', 'numGroups', 'suggestedNumPlayers', 'exchangeRate']) type(ir[k], 'number', 'app.' + k);
type(ir.groupByArrival, 'boolean', 'app.groupByArrival');
if (ir.options !== undefined) {
if (!Array.isArray(ir.options)) problems.push('app.options: should be a list');
else ir.options.forEach((o, i) => {
const where = 'app.options[' + i + ']';
if (!isObject(o) || typeof o.name !== 'string') return problems.push(where + ': should have a name');
if (!OPTION_TYPES.includes(o.type)) problems.push(where + '.type: should be one of ' + OPTION_TYPES.join(', '));
if (o.type === 'select' && !Array.isArray(o.values)) problems.push(where + '.values: should be a list');
});
}
if (ir.values !== undefined && !isObject(ir.values)) problems.push('app.values: should be an object');
programs(ir.functions, 'app.functions');
programs(ir.messages, 'app.messages');
programs(ir.hooks, 'app.hooks', Object.keys(APP_HOOKS));
if (ir.fields !== undefined) {
if (!isObject(ir.fields)) problems.push('app.fields: should be an object');
else for (const [name, f] of Object.entries(ir.fields)) {
const where = 'app.fields["' + name + '"]';
if (!isObject(f)) { problems.push(where + ': should be an object'); continue; }
if (!FIELD_TYPES.includes(f.type)) problems.push(where + '.type: should be one of ' + FIELD_TYPES.join(', '));
for (const k of ['min', 'max']) if (f[k] !== undefined && typeof f[k] !== 'number') program(f[k], where + '.' + k);
if (f.choices !== undefined && !Array.isArray(f.choices)) program(f.choices, where + '.choices');
}
}
screen(ir.screen, 'app.screen');
if (!Array.isArray(ir.stages)) {
problems.push('app.stages: should be a list');
} else {
const ids = new Set();
ir.stages.forEach((s, i) => {
const where = 'app.stages[' + i + ']';
if (!isObject(s)) return problems.push(where + ': should be an object');
keys(s, STAGE_KEYS, where);
if (typeof s.id !== 'string' || s.id === '') problems.push(where + '.id: should be a name');
else if (ids.has(s.id)) problems.push(where + '.id: "' + s.id + '" is used by an earlier stage');
ids.add(s.id);
for (const k of ['duration', 'playerDuration']) {
if (s[k] !== undefined && typeof s[k] !== 'number') program(s[k], where + '.' + k);
}
type(s.endOnTimeout, 'boolean', where + '.endOnTimeout');
for (const k of ['waitToStart', 'waitToEnd', 'waitForAllGroups', 'waitOnTimerEnd']) type(s[k], 'boolean', where + '.' + k);
if (s.timeoutGrace !== undefined && s.timeoutGrace !== null) type(s.timeoutGrace, 'number', where + '.timeoutGrace');
if (s.participate !== undefined) program(s.participate, where + '.participate');
if (s.formFields !== undefined && !(Array.isArray(s.formFields) && s.formFields.every((f) => typeof f === 'string'))) {
problems.push(where + '.formFields: should be a list of field names');
}
programs(s.programs, where + '.programs', STAGE_PROGRAMS);
screen(s.screen, where + '.screen');
if (s.values !== undefined && !isObject(s.values)) problems.push(where + '.values: should be an object');
});
}
return problems;
}
/**
* Compiles program into a JS function, run with `app` and `Utils` in scope. Languages other
* than JS come with their dialects.
* @param {Program} program
* @param {App} app
* @param {string} where Names the program in errors, e.g. "stages.results.groupStart".
*/
function compileProgram(program, app, where) {
if (program.lang !== 'js') {
throw new Error(where + ': programs in "' + program.lang + '" cannot run yet');
}
// Method shorthand, as Function.prototype.toString gives for `{ f(x) {...} }`, is not an expression.
let source = program.source.trim();
if (/^(async\s+)?[A-Za-z_$][\w$]*\s*\(/.test(source) && !/^(async\s+)?function\b/.test(source)) {
source = source.replace(/^(async\s+)?/, (m) => m + 'function ');
}
const filename = (app.appPath || 'app') + '#' + where;
const fn = vm.compileFunction('return (' + source + '\n);', ['app', 'Utils'], { filename })(app, Utils);
if (typeof fn !== 'function') {
throw new Error(where + ': is not a function');
}
return fn;
}
/**
* Makes app (a new App, with any options given to it already set) the app ir describes.
* Throws an Error listing ir's problems if it is not valid.
*/
function applyIR(app, ir) {
const problems = validate(ir);
if (problems.length > 0) {
throw new Error('The app description has problems:\n- ' + problems.join('\n- '));
}
for (const k of ['title', 'description', 'numPeriods', 'groupSize', 'numGroups', 'groupMatchingType', 'groupByArrival', 'exchangeRate', 'suggestedNumPlayers']) {
if (ir[k] !== undefined) app[k] = ir[k];
}
for (const [k, v] of Object.entries(ir.values || {})) app[k] = v;
for (const o of ir.options || []) {
if (o.type === 'number') app.addNumberOption(o.name, o.default, o.min, o.max, o.step, o.description);
else if (o.type === 'text') app.addTextOption(o.name, o.default, o.description);
else app.addSelectOption(o.name, o.values, o.description);
}
for (const [k, p] of Object.entries(ir.functions || {})) app[k] = compileProgram(p, app, 'functions.' + k);
for (const [k, p] of Object.entries(ir.messages || {})) app.messages[k] = compileProgram(p, app, 'messages.' + k);
for (const [k, p] of Object.entries(ir.hooks || {})) app[APP_HOOKS[k]] = compileProgram(p, app, 'hooks.' + k);
if (ir.fields !== undefined) {
app.fields = {};
for (const [name, f] of Object.entries(ir.fields)) {
const field = Object.assign({}, f);
for (const k of ['min', 'max', 'choices']) {
if (f[k] != null && typeof f[k] === 'object' && !Array.isArray(f[k])) field[k] = compileProgram(f[k], app, 'fields.' + name + '.' + k);
}
app.fields[name] = field;
}
}
applyScreen(app, ir.screen || {}, app, 'screen');
for (const s of ir.stages) {
const stage = app.newStage(s.id);
for (const k of ['waitToStart', 'waitToEnd', 'waitForAllGroups', 'waitOnTimerEnd', 'timeoutGrace', 'endOnTimeout']) {
if (s[k] !== undefined) stage[k] = s[k];
}
for (const [k, v] of Object.entries(s.values || {})) stage[k] = v;
if (s.formFields !== undefined) stage.formFields = s.formFields.slice();
const where = 'stages.' + s.id;
// A duration is seconds, or a program giving them for a group or a player.
for (const [k, field, method] of [['duration', 'duration', 'getGroupDuration'], ['playerDuration', 'clientDuration', 'getClientDuration']]) {
if (typeof s[k] === 'number') stage[field] = s[k];
else if (s[k] !== undefined) stage[method] = compileProgram(s[k], app, where + '.' + k);
}
if (s.participate !== undefined) stage.canPlayerParticipate = compileProgram(s.participate, app, where + '.participate');
for (const [k, p] of Object.entries(s.programs || {})) stage[k] = compileProgram(p, app, where + '.' + k);
applyScreen(app, s.screen || {}, stage, where + '.screen');
}
return app;
}
function applyScreen(app, screen, target, where) {
if (screen.renderer !== undefined) target.renderer = screen.renderer;
if (screen.active !== undefined) target.activeScreen = screen.active;
if (screen.waiting !== undefined) target.waitingScreen = screen.waiting;
for (const [k, p] of Object.entries(screen.computed || {})) app.vueComputed[k] = compileProgram(p, app, where + '.computed.' + k);
for (const [k, p] of Object.entries(screen.methods || {})) app.vueMethods[k] = compileProgram(p, app, where + '.methods.' + k);
}
// Fields of App and Stage that are jtree's own workings, not part of what an app describes.
const APP_INTERNAL = ['fields', 'id', 'shortId', 'appDir', 'appFilename', 'appPath', 'jt', 'session', 'stages', 'periods',
'options', 'optionValues', 'givenOptions', 'appjs', 'messages', 'vueComputed', 'vueMethods', 'started',
'finished', 'hasError', 'errorFile', 'errorLine', 'errorPosition', 'outputDelimiter', 'keyComparisons',
'activeScreen', 'waitingScreen', 'renderer', 'indexInSession', 'groups'];
const STAGE_INTERNAL = ['id', 'name', 'app', 'sourceFile', 'activeScreen', 'waitingScreen', 'renderer',
'duration', 'clientDuration', 'waitToStart', 'waitToEnd', 'waitForAllGroups', 'waitOnTimerEnd', 'timeoutGrace',
'endOnTimeout', 'getGroupDuration', 'getClientDuration', 'formFields'];
const js = (fn) => ({ lang: 'js', source: fn.toString() });
const isJSON = (v) => {
try {
return JSON.stringify(v) !== undefined && !Utils.isFunction(v);
} catch (err) {
return false;
}
};
/**
* Describes a loaded jtree app as an IR. Programs are the source of its functions: one
* that uses names from its app file other than app and Utils (a helper function defined
* there, say) will not run from the IR.
* @param {App} app
* @param {App} fresh A new App for the same file, whose code has not run: what is the default.
*/
function appToIR(app, fresh) {
const ir = { ir: 1, dialect: 'jtree' };
for (const k of ['title', 'description', 'numPeriods', 'groupSize', 'numGroups', 'groupMatchingType', 'groupByArrival', 'exchangeRate', 'suggestedNumPlayers']) {
if (app[k] !== undefined && app[k] !== fresh[k]) ir[k] = app[k];
}
const optionNames = app.options.map((o) => o.name);
if (app.options.length > 0) {
ir.options = app.options.map((o) => {
const out = { name: o.name, type: o.type };
if (o.type === 'select') out.values = o.values;
else out.default = o.defaultVal;
for (const k of ['min', 'max', 'step', 'description']) if (o[k] != null) out[k] = o[k];
return out;
});
}
const hooks = {};
for (const [name, method] of Object.entries(APP_HOOKS)) {
if (Object.prototype.hasOwnProperty.call(app, method)) hooks[name] = js(app[method]);
}
const hookMethods = Object.values(APP_HOOKS);
const values = {};
const functions = {};
for (const k of Object.keys(app)) {
if (APP_INTERNAL.includes(k) || optionNames.includes(k) || hookMethods.includes(k) || ir[k] !== undefined) continue;
const v = app[k];
if (Utils.isFunction(v)) {
if (v !== fresh[k]) functions[k] = js(v);
} else if (isJSON(v) && JSON.stringify(v) !== JSON.stringify(fresh[k])) {
values[k] = v;
}
}
if (Object.keys(values).length > 0) ir.values = values;
if (Object.keys(functions).length > 0) ir.functions = functions;
if (Object.keys(hooks).length > 0) ir.hooks = hooks;
if (app.fields != null && Object.keys(app.fields).length > 0) {
ir.fields = {};
for (const [name, f] of Object.entries(app.fields)) {
ir.fields[name] = Object.fromEntries(Object.entries(f).map(([k, v]) => [k, Utils.isFunction(v) ? js(v) : v]));
}
}
if (Object.keys(app.messages).length > 0) {
ir.messages = Object.fromEntries(Object.entries(app.messages).map(([k, f]) => [k, js(f)]));
}
const screen = screenToIR(app, fresh);
if (Object.keys(app.vueComputed).length > 0) screen.computed = Object.fromEntries(Object.entries(app.vueComputed).map(([k, f]) => [k, js(f)]));
if (Object.keys(app.vueMethods).length > 0) screen.methods = Object.fromEntries(Object.entries(app.vueMethods).map(([k, f]) => [k, js(f)]));
if (Object.keys(screen).length > 0) ir.screen = screen;
const freshStage = fresh.newStage('x');
ir.stages = app.stages.map((stage) => {
const s = { id: stage.id };
for (const k of ['duration', 'waitToStart', 'waitToEnd', 'waitForAllGroups', 'waitOnTimerEnd', 'timeoutGrace', 'endOnTimeout']) {
if (stage[k] !== freshStage[k]) s[k] = stage[k];
}
if (stage.clientDuration !== freshStage.clientDuration) s.playerDuration = stage.clientDuration;
if (Object.prototype.hasOwnProperty.call(stage, 'getGroupDuration')) s.duration = js(stage.getGroupDuration);
if (Object.prototype.hasOwnProperty.call(stage, 'getClientDuration')) s.playerDuration = js(stage.getClientDuration);
if (Object.prototype.hasOwnProperty.call(stage, 'canPlayerParticipate')) s.participate = js(stage.canPlayerParticipate);
if (Array.isArray(stage.formFields)) s.formFields = stage.formFields.slice();
const programs = {};
for (const k of STAGE_PROGRAMS) {
if (Object.prototype.hasOwnProperty.call(stage, k)) programs[k] = js(stage[k]);
}
if (Object.keys(programs).length > 0) s.programs = programs;
const stageScreen = screenToIR(stage, freshStage);
if (Object.keys(stageScreen).length > 0) s.screen = stageScreen;
const stageValues = {};
for (const k of Object.keys(stage)) {
if (STAGE_INTERNAL.includes(k) || STAGE_PROGRAMS.includes(k) || k === 'canPlayerParticipate') continue;
const v = stage[k];
if (!Utils.isFunction(v) && isJSON(v) && JSON.stringify(v) !== JSON.stringify(freshStage[k])) stageValues[k] = v;
}
if (Object.keys(stageValues).length > 0) s.values = stageValues;
return s;
});
return ir;
}
function screenToIR(obj, fresh) {
const screen = {};
if (obj.renderer !== undefined && obj.renderer !== fresh.renderer) screen.renderer = obj.renderer;
if (obj.activeScreen != null && obj.activeScreen !== fresh.activeScreen) screen.active = obj.activeScreen;
if (obj.waitingScreen != null && obj.waitingScreen !== fresh.waitingScreen) screen.waiting = obj.waitingScreen;
return screen;
}
module.exports = { validate, applyIR, appToIR, compileProgram };