const fs = require('fs-extra');
const path = require('path');
const { readTreatment } = require('./ztt.js');
const ztq = require('./ztq.js');
const { Run } = require('./runtime.js');
const exportTables = require('./export.js');
/*
* z-Tree treatments (.ztt files) as jtree apps: read by ztt.js, their programs run by lang.js
* over z-Tree's tables (runtime.js), their screens drawn by render.js and shown by
* participant/ztree.js. See plans/ztree-otree-dialects.md, section 5.
*
* How a treatment becomes a jtree app:
* - its periods are periods; all its subjects are one jtree group (z-Tree's Group is a variable);
* - each stage is a stage: "wait for all" stages start when every subject is there, the others as
* each subject gets there; their programs run then, and Participate decides who takes part;
* - a stage's timeout is each subject's time on it: shown in the header box, and ending the stage
* if the stage leaves after its timeout;
* - buttons are handled by the server (ztreeButton): inputs, checkers, programs, leaving the stage;
* every subject's screen is drawn again after anything changes.
*/
const SCREEN_ASSETS = '<link rel="stylesheet" href="/participant/ztree.css"><script src="/participant/ztree-plot.js"></script><script src="/participant/ztree.js"></script>';
/** Ends player's stage after what is running now. */
function endSoon(player) {
player.session().pushMessage(player, true, 'endStage');
}
/** The treatment's run in app (a session's app): made when it starts. */
function runOf(app) {
if (app.ztreeRun == null) {
app.ztreeRun = new Run(app.ztree.treatment, app);
app.ztreeRun.onChange = () => refresh(app);
}
return app.ztreeRun;
}
/** The subject index of player (their participant's place in the session). */
function subjectIndex(player) {
return Object.keys(player.session().participants).indexOf(String(player.participant.id));
}
/** Draws again the screen of each of app's players who is in it, and sends it if it changed. */
function refresh(app) {
const session = app.session;
if (session == null) return;
tablesChanged(session);
for (const participant of Object.values(session.participants)) {
const player = participant.player;
if (player == null || player.app() !== app || player.stage == null || player.stage.ztree == null) continue;
leaveIfAsked(player);
draw(player);
}
}
/** A subject whose LeaveStage a program set to 1 leaves their stage (z-Tree's LeaveStage); it is 0 again. */
function leaveIfAsked(player) {
if (player.status !== 'playing') return;
const run = player.app().ztreeRun;
const state = run == null ? null : run.periods[player.group.period.id - 1];
const rec = state == null ? null : state.subjects[subjectIndex(player)];
if (rec == null || lang().num(rec.LeaveStage) !== 1) return;
rec.LeaveStage = 0;
endSoon(player);
}
/** Draws player's screen (their stage's active screen, or the waiting screen they are on). */
/**
* Player presses a button of their screen: data is {stage, box, button, values, others, record,
* itemButton}, as their page sends it ('ztreeButton'). Returns {error} (the message z-Leaf would
* show), {leave} (whether they leave the stage), or null if they are not playing that stage.
*/
function pressButton(app, player, data) {
if (player == null || player.app() !== app || player.stage == null || player.stage.ztree == null || player.status !== 'playing') return null;
if (data == null || data.stage !== player.stage.id) return null;
const run = runOf(app);
if (data.record != null) {
const sel = run.selected.get(player.participant.id) || {};
sel[data.box] = data.record;
run.selected.set(player.participant.id, sel);
}
const result = run.press(player.group.period.id - 1, subjectIndex(player), player.stage.ztree, data);
if (result.error) return result;
if (result.leave) endSoon(player);
refresh(app);
return result;
}
function draw(player, which) {
const app = player.app();
const run = runOf(app);
const stage = player.stage;
if (which == null) which = player.ztreeWhich || (player.status === 'playing' ? 'active' : 'waiting');
player.ztreeWhich = which;
const shown = which === 'active' || player.ztreeWaitingStage == null ? stage : app.stages[player.ztreeWaitingStage];
let html;
try {
html = run.screen(player.group.period.id - 1, subjectIndex(player), shown.ztree, which, player.participant.id,
which === 'active' ? player.ztreeDeadline : null);
} catch (err) {
run.note('drawing a screen: ' + err.message);
html = '<div class="ztree-error">This screen could not be shown: ' + String(err.message).replace(/</g, '<') + '</div>';
}
if (html !== drawn.get(player)) {
drawn.set(player, html);
// Numbered, so that a page shows the newest screen it got, whichever way it came
// ('ztreeScreen', or jtree's update of the player, which may come later).
player.ztreeVersion = (player.ztreeVersion || 0) + 1;
player.ztreeHtml = html.replace('<div class="ztree-screen"', '<div class="ztree-screen" data-version="' + player.ztreeVersion + '"');
const io = player.session().io();
if (io != null) io.to(player.participant.roomId()).emit('ztreeScreen', { html: player.ztreeHtml, stage: player.stage.id, version: player.ztreeVersion });
}
}
/** Tells admins a session's z-Tree tables changed (at most every half second), to ask for them. */
const tablesChangedTimers = new WeakMap();
function tablesChanged(session) {
if (tablesChangedTimers.has(session)) return;
const timer = setTimeout(() => {
tablesChangedTimers.delete(session);
const ss = session.jt != null ? session.jt.socketServer : null;
if (ss != null) ss.emitToAdmins('ztreeTablesChanged', { sessionId: session.id });
}, 500);
if (timer.unref) timer.unref();
tablesChangedTimers.set(session, timer);
}
/** The screen last drawn for each player (without its number). */
const drawn = new WeakMap();
function defineStage(app, zstage, index, treatment) {
const stage = app.newStage('stage' + (index + 1));
stage.ztree = zstage;
stage.title = zstage.name;
const startIfPossible = zstage.startIfPossible === true;
// Leaving the stage when its time is up is z-Tree's default. (The options' bit 2, set on the
// examples' market stages, is not known yet.)
const leaveAfterTimeout = true;
stage.wrapPlayingScreenInFormTag = 'no';
stage.addOKButtonIfNone = false;
stage.waitToEnd = false;
stage.waitToStart = !startIfPossible;
stage.waitForAllGroups = !startIfPossible;
stage.endOnTimeout = leaveAfterTimeout;
stage.useAppWaitingScreen = false;
const id = stage.id;
stage.activeScreen = SCREEN_ASSETS + '<div class="ztree-page" v-if="stage.id == \'' + id + '\' && player.status == \'playing\'" v-html="player.ztreeHtml"></div>';
stage.waitingScreen = '<div class="ztree-page" v-if="stage.id == \'' + id + '\' && player.status != \'playing\'" v-html="player.ztreeHtml"></div>';
const started = new Set(); // period/participant: programs run
const startSubjects = (period, players) => {
const run = runOf(app);
const p = period.id - 1;
const view = run.view(p);
const subjects = players.map((pl) => run.periods[p].subjects[subjectIndex(pl)]).filter(Boolean);
for (const rec of subjects) rec.Participate = 1;
// Its table loaders, once a period, before its programs; its dumpers after them.
const loaded = run.periods[p].loaded = run.periods[p].loaded || new Set();
if (!loaded.has(zstage)) {
loaded.add(zstage);
run.loadTables(p, zstage.loaders);
}
run.runPrograms(zstage.programs, view, subjects, { globalsOnce: startIfPossible ? run.periods[p].globalsRun : null });
run.dumpTables(p, zstage.dumpers);
};
if (!startIfPossible) {
stage.allGroupsStart = (period) => {
const players = period.groups.flatMap((g) => g.players);
players.forEach((pl) => started.add(period.id + '/' + pl.participant.id));
startSubjects(period, players);
refresh(app);
};
}
stage.canPlayerParticipate = (player) => {
const key = player.group.period.id + '/' + player.participant.id;
if (!started.has(key)) {
started.add(key);
startSubjects(player.group.period, [player]);
}
const rec = runOf(app).periods[player.group.period.id - 1].subjects[subjectIndex(player)];
return rec == null || lang().num(rec.Participate) !== 0;
};
// The stage's timeout: an expression, for each subject (0 or less: none).
stage.getClientDuration = (player) => {
const run = runOf(app);
const p = player.group.period.id - 1;
const rec = run.periods[p].subjects[subjectIndex(player)];
const seconds = zstage.timeout ? lang().num(run.value(zstage.timeout, { rt: run.view(p), table: 'subjects', rec, outer: null })) : 0;
return seconds > 0 ? seconds : 0;
};
stage.playerStart = (player) => {
const seconds = stage.getClientDuration(player);
player.ztreeDeadline = seconds > 0 ? Date.now() + seconds * 1000 : null;
draw(player, 'active');
// What the subject's stage programs made (records in a list, ...) shows on the others' screens too.
refresh(app);
};
stage.playerEnd = (player) => {
// Until the next stage, the subject sees this stage's waiting screen.
player.ztreeWaitingStage = stage.indexInApp();
copyToPlayer(player);
draw(player, 'waiting');
refresh(app);
};
return stage;
}
/** The subject's variables as the player's fields (for jtree's data), and Profit as points. */
function copyToPlayer(player) {
const run = runOf(player.app());
const rec = run.periods[player.group.period.id - 1].subjects[subjectIndex(player)];
if (rec == null) return;
for (const [k, v] of Object.entries(rec)) {
if (k === '_id' || (v != null && typeof v === 'object')) continue;
if (!/^[A-Z]/.test(k) && Object.prototype.hasOwnProperty.call(player, k) && !(k in (player.ztreeFields || {}))) continue;
(player.ztreeFields = player.ztreeFields || {})[k] = true;
player[k] = v;
}
player.points = lang().num(rec.Profit);
}
let langModule = null;
const lang = () => (langModule = langModule || require('./lang.js'));
module.exports = {
name: 'ztree',
detect(appPath) {
return /\.zt[tq]$/i.test(appPath) || (/\.txt$/i.test(appPath) && require('./ztext.js').isTextFile(appPath));
},
/** Makes app the z-Tree treatment in appPath. */
define(app) {
const buf = fs.readFileSync(app.appPath);
// A questionnaire file (.ztq) runs as a treatment of its questionnaires (see ztq.js).
// (A text export says which it is: questionnaires "…" { } or treatment "…" { }.)
const questionnaires = /\.ztq$/i.test(app.appPath) || /^\s*questionnaires\b/.test(buf.subarray(0, 64).toString('latin1').replace(/^\uFEFF|^\xEF\xBB\xBF/, ''));
const treatment = questionnaires ? ztq.toTreatment(ztq.readQuestionnaires(buf)) : readTreatment(buf);
app.ztree = { treatment };
// Not sent to pages, nor saved with the app.
app.outputHideAuto.push('ztree', 'ztreeRun');
app.appjs = '';
app.shortId = path.basename(app.appPath, path.extname(app.appPath));
app.title = app.shortId;
app.description = treatment.questionnaire
? 'z-Tree questionnaire, ' + treatment.stages.length + ' page' + (treatment.stages.length === 1 ? '' : 's') + '.'
: 'z-Tree treatment, ' + treatment.subjects.length + ' subjects, ' + treatment.periods.length + ' period' +
(treatment.periods.length === 1 ? '' : 's') + ', ' + treatment.stages.length + ' stages.';
app.numPeriods = Math.max(1, treatment.periods.length);
if (!treatment.questionnaire) app.suggestedNumParticipants = treatment.subjects.length;
app.playerFieldsNotInOutput = ['ztreeHtml', 'ztreeWhich', 'ztreeWaitingStage', 'ztreeDeadline', 'ztreeFields', 'ztreeVersion'];
app.waitingScreen = SCREEN_ASSETS + '<div class="ztree-page" v-if="player.stage == null || player.stage.ztree == null" v-html="player.ztreeHtml"></div>';
if (treatment.warnings.length) app.ztree.warnings = treatment.warnings;
app.periodStart = (period) => {
const run = runOf(app);
run.startPeriod(period.id - 1, Object.keys(app.session.participants).length);
};
app.periodEnd = (period) => {
const run = runOf(app);
run.endPeriod(period.id - 1);
for (const group of period.groups) for (const player of group.players) copyToPlayer(player);
};
treatment.stages.forEach((s, i) => defineStage(app, s, i, treatment));
// A button pressed: {stage, box, button, values, record}.
app.messages.ztreeButton = function(data) {
const player = this.participant.player;
const result = pressButton(app, player, data);
if (result == null) return;
const io = player.session().io();
if (io == null) return;
if (result.error) {
io.to(player.participant.roomId()).emit('ztreeMessage', { text: result.error, box: data.box });
return;
}
io.to(player.participant.roomId()).emit('ztreeAccepted', { box: data.box });
};
app.messages.ztreeButton.convertsOwnValues = true;
// A row chosen in a contract list: {stage, box, record}.
app.messages.ztreeSelect = function(data) {
const player = this.participant.player;
if (player == null || player.app() !== app || data == null) return;
const run = runOf(app);
const sel = run.selected.get(player.participant.id) || {};
sel[data.box] = data.record;
run.selected.set(player.participant.id, sel);
draw(player);
};
app.messages.ztreeSelect.convertsOwnValues = true;
// A plot input used: {stage, box, input, x, y, x0, y0, x1, y1}.
app.messages.ztreePlot = function(data) {
const player = this.participant.player;
if (player == null || player.app() !== app || player.stage == null || player.stage.ztree == null || player.status !== 'playing') return;
if (data == null || data.stage !== player.stage.id) return;
const result = runOf(app).plotInput(player.group.period.id - 1, subjectIndex(player), player.stage.ztree, data);
if (result.error) player.session().io().to(player.participant.roomId()).emit('ztreeMessage', { text: result.error, box: data.box });
refresh(app);
};
app.messages.ztreePlot.convertsOwnValues = true;
// A chat entry: {stage, box, text}.
app.messages.ztreeChat = function(data) {
const player = this.participant.player;
if (player == null || player.app() !== app || player.stage == null || player.stage.ztree == null || data == null || data.stage !== player.stage.id) return;
runOf(app).chat(player.group.period.id - 1, subjectIndex(player), player.stage.ztree, data);
refresh(app);
};
app.messages.ztreeChat.convertsOwnValues = true;
// Downloads: the treatment's tables, as z-Tree writes them.
app.exporters = (session) => [{
id: 'ztree-tables-' + app.shortId,
name: 'z-Tree tables of ' + app.shortId,
filename: () => app.shortId + '-' + session.id + '.xls',
contentType: 'text/tab-separated-values',
write: () => {
const own = session.apps.find((a) => a.ztree != null && a.appPath === app.appPath) || app;
return own.ztreeRun == null ? '' : exportTables(own.ztreeRun, session);
},
}];
},
runOf,
draw,
refresh,
pressButton,
};