const crypto = require('crypto');
const fs = require('fs-extra');
const path = require('path');
const runtime = require('./runtime.js');
const staticFiles = require('./static.js');
const otreeExport = require('./export.js');
const Group = require('../../Group.js');
const Player = require('../../Player.js');
/*
* oTree apps: a folder with an __init__.py in oTree's format (one file: C, Subsession, Group,
* Player, functions and pages), or in its older format (models.py and pages.py, methods with
* self, Django templates in templates/<app>/), and its pages' templates. Its Python runs in Pyodide
* (runtime.js), with jtree's otree.api (python/otree/api.py), whose models stand for jtree's
* objects. See plans/ztree-otree-dialects.md, section 4.
*
* How an oTree app becomes a jtree app:
* - rounds are periods; PLAYERS_PER_GROUP the group size, grouped in order and kept in later
* rounds (oTree's default); creating_session runs when each round starts (app.periodStart).
* - each Page is a stage players go through on their own: form_fields are its form's fields
* (checked as app.fields, with <field>_min/_max/_choices, then <field>_error_message and the
* page's error_message), is_displayed who plays it, before_next_page its playerEnd, timeouts
* each player's time. Its template is rendered by Python when the player starts it.
* - each WaitPage is a stage that waits for the group (or all groups, wait_for_all_groups) and
* ends straight away, running after_all_players_arrive; group_by_arrival_time groups players
* by arrival.
*/
const KINDS = { int: 'int', number: 'number', currency: 'number', string: 'string', bool: 'bool' };
// oTree's pages are written for Bootstrap 5. jtree moves <link>s in screens to the page's head.
const STYLES = '<link rel="stylesheet" href="/shared/bootstrap-5.3.8/bootstrap.min.css">' +
'<link rel="stylesheet" href="/participant/otree.css">' +
'<script src="/participant/otree.js"></script>';
/** The Python package an app's folder is loaded as. */
function packageFor(appPath) {
return 'otree_' + crypto.createHash('sha1').update(path.resolve(appPath)).digest('hex').substring(0, 12);
}
// Load the app catalogue again once Python has started, for each jtree that waited for it.
const reloaders = new WeakMap();
function reloadWhenReady(jt) {
if (jt == null || jt.data == null) return;
if (!reloaders.has(jt)) {
reloaders.set(jt, () => {
jt.data.reloadApps();
if (jt.socketServer != null) jt.socketServer.refreshAdmins();
});
}
runtime.whenReady(reloaders.get(jt));
}
/** Where in the app's files a Python error happened, from its traceback. */
function errorPosition(err, pkg, dir) {
const re = new RegExp('File "/apps/' + pkg + '/([^"]+)", line (\\d+)', 'g');
let last = null;
for (const m of String(err.message || err).matchAll(re)) last = m;
if (last == null) return null;
return { file: path.join(dir, last[1]), line: last[2], column: 'unknown' };
}
function fieldFor(model, name, f, pkg) {
const field = {};
if (f.label != null) field.label = f.label;
field.type = KINDS[f.kind] || 'string';
if (f.choices != null || f.callbacks.includes('choices')) {
field.type = 'choice';
field.choices = f.choices;
}
for (const k of ['min', 'max']) {
if (f[k] != null) field[k] = f[k];
}
if (f.blank) field.blank = true;
for (const cb of ['min', 'max', 'choices']) {
if (f.callbacks.includes(cb)) {
field[cb] = (player) => runtime.getBridge().field_callback(pkg, model, name, cb, player);
}
}
return field;
}
/** The session's chat with key: {messages, members (participant ids)}. */
function chatFor(session, key) {
if (session.otreeChats == null) session.otreeChats = {};
if (session.otreeChats[key] == null) session.otreeChats[key] = { messages: [], members: [] };
return session.otreeChats[key];
}
/**
* The round's initial values and creating_session, for an app that groups players by arrival: in
* oTree they are every participant's player's, which jtree makes only as they arrive
* (Period#arrivalGroupId). So they go into players of their own, in groups in the participants'
* order, and what they set there is given to the participants' players when they arrive
* (period.arrivalFields).
*/
function creatingSessionBeforeArrival(app, pkg, period, creatingSession) {
const size = app.groupSize || 1;
const groups = [];
const players = [];
for (const participant of Object.values(period.app.session.participants)) {
if (groups.length === 0 || groups[groups.length - 1].players.length >= size) {
groups.push(new Group.new(groups.length + 1, period));
}
const group = groups[groups.length - 1];
const player = new Player.new(participant.id, participant, group, group.players.length + 1);
group.players.push(player);
players.push(player);
}
// The round's own (subsession) initial values on the round itself; the players' on theirs.
runtime.getBridge().init_fields(pkg, period);
const before = Object.create(period);
before.groups = groups;
runtime.getBridge().init_fields(pkg, before);
if (creatingSession) runtime.getBridge().creating_session(pkg, before);
period.arrivalFields = {};
for (const player of players) {
const fresh = new Player.new(player.id, player.participant, player.group, player.idInGroup);
const fields = {};
for (const key of Object.keys(player)) {
if (!['group', 'participant'].includes(key) && JSON.stringify(player[key]) !== JSON.stringify(fresh[key])) {
fields[key] = player[key];
}
}
period.arrivalFields[player.participant.id] = fields;
}
}
/** End player's (wait) stage, after what is running now. */
function endSoon(player) {
player.session().pushMessage(player, true, 'endStage');
}
function definePage(app, page, pkg) {
const stage = app.newStage(page.name);
const has = (m) => page.methods.includes(m);
const call = (method, player, ...args) => runtime.getBridge().call_page(pkg, page.name, method, player, ...args);
if (has('is_displayed')) {
stage.canPlayerParticipate = (player) => !!call('is_displayed', player);
}
// Pages have their own Next button (next_button), wait pages none.
stage.addOKButtonIfNone = false;
if (page.kind === 'wait') {
stage.wrapPlayingScreenInFormTag = 'no';
stage.waitToStart = true;
stage.waitToEnd = true;
stage.activeScreen = STYLES + '<div class="otree-page"><h2 class="otree-title">' + page.title_text +
'</h2><p>' + page.body_text + '</p></div>';
if (page.group_by_arrival_time) {
app.groupByArrival = true;
}
if (page.wait_for_all_groups) {
stage.waitForAllGroups = true;
if (page.after_all_players_arrive) {
stage.allGroupsStart = (period) => runtime.getBridge().after_all_players_arrive(pkg, page.name, period, 'subsession');
}
} else if (page.after_all_players_arrive) {
stage.groupStart = (group) => runtime.getBridge().after_all_players_arrive(pkg, page.name, group, 'group');
}
stage.playerStart = endSoon;
return;
}
// A page: each player on their own.
stage.wrapPlayingScreenInFormTag = 'yes';
stage.waitToStart = false;
stage.waitToEnd = false;
const model = page.form_model || 'player';
stage.formFields = page.form_fields.map((f) => model + '.' + f);
if (page.timeout_seconds != null) stage.clientDuration = page.timeout_seconds;
if (has('get_timeout_seconds')) stage.getClientDuration = (player) => call('get_timeout_seconds', player) || 0;
stage.validate = (player, values) => runtime.getBridge().validate(pkg, page.name, player, values) || undefined;
stage.playerStart = (player) => {
try {
player.otreeHtml = runtime.getBridge().render(pkg, page.name, player, staticFiles.urlFor(player.jt().basePath, pkg));
player.otreeJsVars = has('js_vars') ? runtime.getBridge().js_vars(pkg, page.name, player) : null;
player.otreeError = undefined;
} catch (err) {
// Show that the page failed, rather than the page before it.
const message = String(err.message || err).trim().split('\n').pop();
player.otreeError = String(err.message || err);
player.otreeHtml = '<div class="alert alert-danger">This page could not be shown: ' +
message.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) + '</div>';
console.log('Error showing page ' + page.name + ' to ' + player.roomId() + ': ' + err.message);
}
};
if (has('live_method')) {
stage.otreeLive = true;
}
if (has('before_next_page') || has('app_after_this_page')) {
stage.playerEnd = (player) => {
if (has('before_next_page')) {
call('before_next_page', player, !!player.timedOut);
}
if (has('app_after_this_page')) {
// The app to go on to, by name, from the session's apps after this one.
const apps = player.session().apps;
const upcoming = apps.slice(player.app().indexInSession()).map((a) => a.shortId);
const name = call('app_after_this_page', player, upcoming);
if (name != null) {
const index = apps.findIndex((a, i) => i >= player.app().indexInSession() && a.shortId === name);
if (index < 0) {
throw new Error('app_after_this_page: ' + name + ' is not one of the apps after this one');
}
player.participant.skipToAppIndex = index + 1;
}
}
};
}
// Only in the current stage's screen: jtree's page holds every stage's (see App#stageContentStart).
stage.activeScreen = STYLES + '<div class="otree-page otree-content" v-if="stage.id == \'' + page.name + '\'" v-html="player.otreeHtml"></div>';
}
module.exports = {
packageFor,
name: 'otree',
detect(appPath) {
// An app's __init__.py, or the models.py of an app in oTree's older format.
if (!['__init__.py', 'models.py'].includes(path.basename(appPath))) return false;
try {
return /\bfrom\s+otree\.api\s+import\b|\bimport\s+otree\.api\b/.test(fs.readFileSync(appPath, 'utf8'));
} catch (err) {
return false;
}
},
/** Makes app the oTree app in appPath's folder. */
define(app) {
app.appjs = fs.readFileSync(app.appPath, 'utf8');
let bridge;
try {
bridge = runtime.getBridge();
} catch (err) {
reloadWhenReady(app.jt);
throw err;
}
const dir = path.dirname(app.appPath);
const pkg = packageFor(app.appPath);
runtime.putApp(pkg, dir);
staticFiles.register(pkg, dir);
let info;
try {
info = JSON.parse(bridge.load(pkg, path.basename(dir)));
} catch (err) {
err.jtreePosition = errorPosition(err, pkg, dir) || undefined;
throw err;
}
app.otree = { pkg, constants: info.constants, roles: info.roles, fields: info.fields };
app.playerFieldsNotInOutput = ['otreeHtml', 'otreeJsVars', 'otreeError', 'otreeChats'];
app.shortId = info.name || path.basename(dir);
app.title = info.name || path.basename(dir);
if (info.doc) app.description = info.doc;
app.numPeriods = info.num_rounds;
if (info.players_per_group != null) app.groupSize = info.players_per_group;
app.groupMatchingType = 'PARTNER_1122';
app.fields = {};
for (const model of ['player', 'group']) {
for (const [name, f] of Object.entries(info.fields[model])) {
app.fields[model + '.' + name] = fieldFor(model, name, f, pkg);
}
}
// Downloads of this app's data: as oTree's per-app CSV, and its custom_export if it has one.
app.exporters = (session) => {
const own = session.apps.find((a) => a.otree != null && a.otree.pkg === pkg) || app;
const out = [{
id: 'otree-app-' + app.shortId,
name: 'oTree CSV of ' + app.shortId,
filename: () => app.shortId + '-' + session.id + '.csv',
contentType: 'text/csv',
write: () => otreeExport.appCSV(session, own),
}];
if (runtime.getBridge().has_custom_export(pkg)) {
out.push({
id: 'otree-custom-' + app.shortId,
name: 'Custom export of ' + app.shortId,
filename: () => app.shortId + '-custom-' + session.id + '.csv',
contentType: 'text/csv',
write: () => otreeExport.csv(JSON.parse(runtime.getBridge().custom_export(pkg, own))),
});
}
return out;
};
// Each round: its fields' initial values, then creating_session (if the app has one).
app.periodStart = (period) => {
if (app.groupByArrival) {
creatingSessionBeforeArrival(app, pkg, period, info.creating_session);
} else {
runtime.getBridge().init_fields(pkg, period);
if (info.creating_session) runtime.getBridge().creating_session(pkg, period);
}
};
for (const page of info.pages) {
definePage(app, page, pkg);
}
// liveSend(data) on a page with a live_method: what it returns goes to the group's
// players' pages, to liveRecv(data); 0 is everyone.
app.messages.liveSend = function(data) {
const player = this.participant.player;
if (player == null || !player.stage.otreeLive || player.status !== 'playing') {
return;
}
const sends = runtime.getBridge().live(pkg, player.stage.id, player, data);
const io = player.session().io();
for (const [id, value] of sends || []) {
for (const p of player.group.players) {
if (id === 0 || p.idInGroup === id) {
io.to(p.participant.roomId()).emit('liveRecv', value);
}
}
}
};
// Its data as the page sent it, not with numbers parsed (see Session#processMessage).
app.messages.liveSend.convertsOwnValues = true;
// {{ chat }}: a page joins the chats it was rendered with (player.otreeChats, by key, with
// the player's nickname), gets what was said, and sends; everyone in the chat gets it.
app.messages.otreeChatJoin = function(data) {
const player = this.participant.player;
const key = data && data.channel;
if (player == null || player.otreeChats == null || player.otreeChats[key] == null) return;
const chat = chatFor(player.session(), key);
if (!chat.members.includes(this.participant.id)) chat.members.push(this.participant.id);
player.session().io().to(this.participant.roomId()).emit('otreeChatHistory', { channel: key, messages: chat.messages });
};
app.messages.otreeChat = function(data) {
const player = this.participant.player;
const key = data && data.channel;
const body = data && typeof data.body === 'string' ? data.body.trim().slice(0, 500) : '';
if (player == null || player.otreeChats == null || player.otreeChats[key] == null || body === '') return;
const session = player.session();
const chat = chatFor(session, key);
const message = { nickname: player.otreeChats[key], body, participant: this.participant.id, time: Date.now() };
chat.messages.push(message);
for (const id of chat.members) {
const member = session.participants[id];
if (member != null) session.io().to(member.roomId()).emit('otreeChatMessage', { channel: key, message });
}
};
app.messages.otreeChatJoin.convertsOwnValues = true;
app.messages.otreeChat.convertsOwnValues = true;
},
};