const fs = require('fs-extra');
const path = require('path');
const lang = require('./lang.js');
const { readTreatment } = require('./ztt.js');
const { variables, arrayDeclarations, leavesStage } = require('./runtime.js');
const { parseLayout, layoutParts, textParts } = require('./render.js');
/*
* Converting a z-Tree treatment (.ztt) to a jtree app (plans/ztree-otree-dialects.md, sections
* 5.7 and 6): a folder with
* - app.jtt: the treatment's periods, parameter table, and stages, its programs translated from
* z-Tree's language to JS, and its screens as jtree screens (Vue templates);
* - ztree.cjs: what the translated code uses (convert-runtime.js);
* - CONVERSION.md: what was converted, and what needs work by hand.
*
* A stage whose buttons all end it is a form: its inputs in app.fields (or the stage's own
* stage.fields), its checkers in stage.validate, its button's programs in stage.playerEnd. Other
* stages are interactive (contract boxes, chat, plots, buttons that do not end the stage, buttons
* with different programs): each button is in stage.buttons (its inputs, checkers and programs),
* run as it is pressed (z.press, from the page's ztButton message, participant/ztree-app.js),
* and the stage goes on. What a screen shows that the page cannot work out (lists' rows, table
* functions, plots' shapes) is worked out on the server for each subject (z.refresh), in
* player.zt_, after anything changes.
*
* z-Tree's tables become jtree's objects: subjects the period's players (their variables the
* players' fields), globals period.globals, contracts period.contracts, other tables
* period.tables, summary app.summary, session participant.session_. A variable is resolved to
* its table when converting, as the z-Tree dialect resolves it when running (lang.js): the
* record's table's, else an enclosing record's, else globals'.
*
* From the command line:
* node server/source/dialects/ztree/convert.js <treatment.ztt> [<output folder>]
*/
const RUNTIME = path.join(__dirname, 'convert-runtime.js');
class Todo extends Error {}
const js = (s) => JSON.stringify(s);
const indent = (text, by) => text.split('\n').map((l) => (l ? by + l : l)).join('\n');
/** Translates z-Tree code (programs, expressions) to JS, with variables resolved by vars. */
class Translator {
constructor(vars, report, arrays = {}) {
this.vars = vars;
this.report = report;
this.arrays = arrays; // arrays declared for each table: {table: {name: dimensions}}
this.n = 0;
}
/** The table of a record (JS) in ctx, if known. */
tableOf(ctx, rec) {
if (rec === 'g') return 'globals';
for (let d = ctx; d != null; d = d.outer) if (d.rec === rec) return d.table;
return null;
}
/** rec's array name: through z.arr if its table declares it (made when first used), else as it is. */
arrayRef(ctx, rec, name) {
const table = this.tableOf(ctx, rec);
if (table != null && (this.arrays[table] || {})[name] != null) return `z.arr(${rec}, ${js(name)}, ${js(table)}, period)`;
return this.prop(rec, name);
}
/** JS for the records of a table named in code (OLD<table>: the period before's). */
records(name) {
const old = /^OLD/.test(name);
const table = old ? name.slice(3) : name;
const period = old ? 'z.before(period)' : 'period';
switch (table) {
case 'subjects': return { table, code: `z.subjects(${period})` };
case 'globals': return { table, code: `[z.globals(${period})]` };
case 'contracts': return { table, code: `z.contracts(${period})` };
case 'summary': return { table, code: 'z.summary(app)' };
case 'session': return { table, code: `z.session(${period})` };
default: return { table, code: `((${period}.tables || {})[${js(table)}] || [])` };
}
}
has(table, name) {
return this.vars[table] != null && this.vars[table].has(name);
}
/** The record (JS) a variable is read from, looking out from ctx. */
owner(ctx, name, scope, depth = 1) {
if (scope === 'globals') return 'g';
let c = ctx;
if (scope === 'outer') for (let k = 0; k < (depth || 1) && c != null; k++) c = c.outer;
if (c == null) throw new Todo(':' + name + ' has no enclosing record here');
for (let d = c; d != null; d = d.outer) {
if (this.has(d.table, name) || d.iterVar === name) return d.rec;
}
if (this.has('globals', name)) return 'g';
return c.rec;
}
/** The record (JS) a variable is set in. */
target(ctx, t) {
if (t.scope === 'globals') return 'g';
if (t.scope === 'outer') return this.owner(ctx, t.name, 'outer', t.depth);
if (this.has(ctx.table, t.name)) return ctx.rec;
for (let d = ctx.outer; d != null; d = d.outer) if (this.has(d.table, t.name)) return d.rec;
if (ctx.table !== 'globals' && this.has('globals', t.name) && !this.has(ctx.table, t.name)) return 'g';
return ctx.rec;
}
prop(rec, name) {
return /^[A-Za-z_$][\w$]*$/.test(name) ? `${rec}.${name}` : `${rec}[${js(name)}]`;
}
expr(node, ctx) {
switch (node.k) {
case 'num': return String(node.v);
case 'str': return js(node.v);
case 'var': {
const rec = this.owner(ctx, node.name, node.scope, node.depth);
return node.index == null ? this.prop(rec, node.name) : `z.at(${this.arrayRef(ctx, rec, node.name)}, ${this.expr(node.index, ctx)}, period)`;
}
case 'neg': return `-${this.atom(node.e, ctx)}`;
case 'not': return `!(${this.expr(node.e, ctx)})`;
case 'bin': {
// Division by zero: by the session's mode (z.div).
if (node.op === '/') return `z.div(${this.expr(node.l, ctx)}, ${this.expr(node.r, ctx)}, period)`;
const op = { '&': '&&', '|': '||', '==': '===', '!=': '!==', '^': '**' }[node.op] || node.op;
return `${this.atom(node.l, ctx)} ${op} ${this.atom(node.r, ctx)}`;
}
case 'call': return this.call(node, ctx);
case 'agg': return this.aggregate(node, ctx);
case 'js': return node.code;
default: throw new Todo('cannot translate ' + node.k);
}
}
/** An expression, in parentheses unless it is one thing. */
atom(node, ctx) {
const code = this.expr(node, ctx);
return ['num', 'str', 'var', 'call', 'agg'].includes(node.k) ? code : `(${code})`;
}
call(node, ctx) {
const a = node.args.map((x) => this.expr(x, ctx));
switch (node.fn) {
case 'if': return `(${a[0]} ? ${a[1]} : ${a[2]})`;
case 'not': return `!(${this.expr(node.args[0], ctx)})`;
case 'abs': case 'exp': case 'sqrt': case 'sin': case 'cos': case 'tan': case 'atan': case 'trunc': case 'sign':
return `Math.${node.fn}(${a[0]})`;
case 'ln': return `Math.log(${a[0]})`;
case 'log': return `Math.log10(${a[0]})`;
case 'power': return `Math.pow(${a[0]}, ${a[1]})`;
case 'min': return `Math.min(${a.join(', ')})`;
case 'max': return `Math.max(${a.join(', ')})`;
case 'random': case 'randomgenerator': return 'Math.random()';
case 'round': case 'rounddown': case 'roundup': case 'mod': return `z.${node.fn}(${a.join(', ')})`;
case 'gettime': return '(Date.now() / 1000)';
case 'pi': return 'Math.PI';
case 'rgb': return `z.rgb(${a.join(', ')})`;
case 'true': return 'true';
case 'false': return 'false';
default: throw new Todo('the function ' + node.fn + ' is not translated');
}
}
aggregate(node, ctx) {
if (node.fn === 'same') {
return `(${this.atom(node.args[0], ctx)} === ${this.atom(node.args[0], ctx.outer || ctx)})`;
}
const { table, code } = node.table == null ? { table: ctx.table, code: this.records(ctx.table).code } : this.records(node.table);
const r = 'r' + (++this.n);
const inner = { table, rec: r, outer: ctx };
let cond = null;
let value = null;
if (node.fn === 'count') cond = node.args[0] || null;
else if (node.args.length >= 2) [cond, value] = node.args;
else value = node.args[0];
const c = cond == null ? 'null' : `(${r}) => ${this.expr(cond, inner)}`;
const v = value == null ? (node.fn === 'find' ? 'null' : '() => 0') : `(${r}) => ${this.expr(value, inner)}`;
if (!['count', 'sum', 'product', 'average', 'minimum', 'maximum', 'median', 'find'].includes(node.fn)) throw new Todo('the table function ' + node.fn + ' is not translated');
return node.fn === 'count' ? `z.count(${code}, ${c})` : `z.${node.fn}(${code}, ${c}, ${v})`;
}
/** Statements, as JS lines. */
statements(stmts, ctx, code) {
const out = [];
for (const s of stmts) {
try {
out.push(...this.statement(s, ctx));
} catch (err) {
if (!(err instanceof Todo)) throw err;
const line = code.split('\n')[s.line - 1] || '';
this.report.push({ status: 'todo', part: ctx.where, note: err.message + ' (line ' + s.line + ': ' + line.trim() + ')' });
out.push('// TODO (from z-Tree, line ' + s.line + '): ' + err.message + '.', '// ' + line.trim());
if (!(err.skip)) out.push(`throw new Error(${js('Not converted from z-Tree: ' + line.trim())});`);
}
}
return out;
}
statement(s, ctx) {
const block = (body, c = ctx) => this.statements(body, c, ctx.code).map((l) => ' ' + l);
switch (s.k) {
case 'set': {
const rec = this.target(ctx, s.target);
const value = this.expr(s.value, ctx);
if (s.target.index != null) return [`z.put(${this.arrayRef(ctx, rec, s.target.name)}, ${this.expr(s.target.index, ctx)}, ${value}, period);`];
return [`${this.prop(rec, s.target.name)} = ${value};`];
}
case 'if': {
const out = [];
s.branches.forEach((b, i) => {
out.push(`${i === 0 ? 'if' : '} else if'} (${this.expr(b.cond, ctx)}) {`, ...block(b.body));
});
if (s.otherwise) out.push('} else {', ...block(s.otherwise));
out.push('}');
return out;
}
case 'while':
return [`while (${this.expr(s.cond, ctx)}) {`, ...block(s.body), '}'];
case 'repeat':
return ['do {', ...block(s.body), `} while (${this.expr(s.cond, ctx)});`];
case 'array': {
const d = s.dims.map((x) => this.expr(x, ctx));
return [`${this.prop(ctx.rec, s.name)} = ${d.length === 1 ? `z.array(1, ${d[0]})` : `z.array(${d.join(', ')})`};`];
}
case 'do': {
const { table, code } = this.records(s.table);
const r = 'r' + (++this.n);
return [`for (const ${r} of ${code}) {`, ...block(s.body, { ...ctx, table, rec: r, outer: ctx, iterVar: undefined }), '}'];
}
case 'for': {
// for (i, ...) { }: i set as it goes, a variable of the record (or \\i of globals).
const a = s.args.map((x) => this.expr(x, ctx));
const range = a.length === 1 ? `z.range(1, ${a[0]})` : `z.range(${a.join(', ')})`;
const v = 'v' + (++this.n);
return [`for (const ${v} of ${range}) {`, ` ${this.prop(this.target(ctx, s.target), s.target.name)} = ${v};`, ...block(s.body), '}'];
}
case 'iter': {
const r = 'r' + (++this.n);
const a = s.args.map((x) => this.expr(x, ctx));
const range = a.length === 1 ? `z.range(1, ${a[0]})` : `z.range(${a.join(', ')})`;
return [`for (const ${r} of ${range}.map((v) => ({ ${s.name}: v }))) {`,
...block(s.body, { ...ctx, table: '__iterator', rec: r, outer: ctx, iterVar: s.name }), '}'];
}
case 'new': {
const r = 'r' + (++this.n);
return [`{`, ` const ${r} = z.newRecord(period, ${js(s.table)}, VARS[${js(s.table)}] || []);`,
...block(s.body, { ...ctx, table: s.table, rec: r, outer: ctx, iterVar: undefined }), '}'];
}
case 'later': {
// Its delay again after each run, for repeat (evaluated where the statement is).
const delay = this.expr(s.delay, ctx);
return [`z.later(period, ${delay}, () => {`, ...block(s.body), ...(s.repeat ? [` return ${delay};`] : []), `}${s.repeat ? ', true' : ''});`];
}
case 'exit':
return ['return;'];
default:
throw new Todo('cannot translate ' + s.k);
}
}
/**
* A program on its table's records: subjects (the players given, as player), globals (g),
* the others' records. Lines of JS, in a function that has period, g, app, and players.
*/
program(p, where, players = 'players') {
const code = p.code || '';
let parsed;
try {
parsed = lang.parse(code);
} catch (err) {
this.report.push({ status: 'todo', part: where, note: 'the program does not parse: ' + err.message });
return ['// TODO: a program that does not parse: ' + err.message, ...code.split('\n').map((l) => '// ' + l)];
}
const table = p.table || 'subjects';
const rec = table === 'subjects' ? 'player' : table === 'globals' ? 'g' : 'rec';
const ctx = { table, rec, outer: null, where, code };
const cond = p.condition ? (() => {
try { return this.expr(lang.parseExpr(p.condition), ctx); } catch (err) {
if (!(err instanceof Todo)) throw err;
this.report.push({ status: 'todo', part: where, note: 'its condition: ' + err.message });
return 'false';
}
})() : null;
const body = this.statements(parsed.body, ctx, code);
const guarded = cond ? [`if (${cond}) {`, ...body.map((l) => ' ' + l), '}'] : body;
const head = `// ${table}.do { … }${p.condition ? ' (if ' + p.condition + ')' : ''}${p.owner ? ' (owner ' + p.owner + ')' : ''}`;
if (table === 'globals') return [head, '{', ...guarded.map((l) => ' ' + l), '}'];
const records = table === 'subjects' ? players : table === 'summary' ? '[z.summary(app)[period.id - 1]]' : this.records(table).code;
return [head, `for (const ${rec} of ${records}) {`, ...guarded.map((l) => ' ' + l), '}'];
}
/**
* A program run on one record (ctx: its table and the JS of it, and the records around it), or,
* with records (JS), on each of them: lines of JS.
*/
programOn(p, where, ctx, records = null) {
const code = p.code || '';
let parsed;
try {
parsed = lang.parse(code);
} catch (err) {
this.report.push({ status: 'todo', part: where, note: 'the program does not parse: ' + err.message });
return ['// TODO: a program that does not parse: ' + err.message, ...code.split('\n').map((l) => '// ' + l)];
}
const c = { ...ctx, where, code };
const cond = p.condition ? this.exprOr(p.condition, c, where, 'false') : null;
const body = this.statements(parsed.body, c, code);
const guarded = cond ? [`if (${cond}) {`, ...body.map((l) => ' ' + l), '}'] : body;
const head = `// ${ctx.table}${records ? '.do' : ''} { … }${p.condition ? ' (if ' + p.condition + ')' : ''}`;
if (records) return [head, `for (const ${ctx.rec} of ${records}) {`, ...guarded.map((l) => ' ' + l), '}'];
return [head, '{', ...guarded.map((l) => ' ' + l), '}'];
}
/** An expression (z-Tree's code) in ctx as JS; if it cannot be, otherwise, and a TODO. */
exprOr(code, ctx, where, otherwise) {
try {
return this.expr(lang.parseExpr(code), { ...ctx, where, code });
} catch (err) {
if (!(err instanceof Todo) && !(err instanceof lang.ZtreeSyntaxError)) throw err;
this.report.push({ status: 'todo', part: where, note: code + ': ' + err.message });
return otherwise;
}
}
/**
* A layout as JS, for z.fmt: {step} (a number: "1", "0.01", or a variable's), or {options}
* (!text: 1 = "yes"; 0 = "no", the values numbers or variables').
*/
layout(layout, ctx, where) {
const text = String(layout || '').trim().replace(/^<>/, '').trim();
const m = /^!(\w+)\s*:(.*)$/s.exec(text);
if (m == null) {
if (text === '') return '{ step: 1 }';
if (/^-?[\d.]+$/.test(text)) return `{ step: ${Number(text)} }`;
return `{ step: ${this.exprOr(text, ctx, where, '1')} }`;
}
const options = [];
for (const part of layoutParts(m[2])) {
if (part.text == null) continue;
const key = /^-?[\d.]+$/.test(part.key) ? String(Number(part.key)) : this.exprOr(part.key, ctx, where, 'NaN');
// A text with values in it (<\\N|1> orders): a function, worked out when it is shown.
const hasValues = textParts(part.text).some((x) => x.expr != null && x.expr.trim() !== '');
options.push(`[${key}, ${hasValues ? `() => ${this.text('<>' + part.text, ctx, where)}` : js(part.text)}]`);
}
return `{ options: [${options.join(', ')}] }`;
}
/** A text (a checker's message, a label) as a JS string expression: "<>" texts with their <var|layout> values. */
text(text, ctx, where) {
const s = String(text == null ? '' : text);
if (!s.startsWith('<>')) return js(s);
const parts = [];
for (const part of textParts(s.slice(2))) {
if (part.text != null) parts.push(js(part.text));
else if (part.expr.trim()) parts.push(`z.fmt(${this.exprOr(part.expr, ctx, where, "''")}, ${this.layout(part.layout, ctx, where)})`);
}
// RTF made with the values (GIMS's labels): its text (z.rtf).
const joined = parts.filter((p) => p !== '""').join(' + ') || '""';
return /^\s*\{\\rtf/.test(s.slice(2)) ? `z.rtf(${joined})` : joined;
}
/** An expression of a subject's (a timeout, an input's minimum), with player and its period in scope. */
subjectExpr(code, where) {
try {
return this.expr(lang.parseExpr(code), { table: 'subjects', rec: 'player', outer: null, where, code });
} catch (err) {
if (!(err instanceof Todo)) throw err;
this.report.push({ status: 'todo', part: where, note: code + ': ' + err.message });
return '0';
}
}
}
// --- Screens --------------------------------------------------------------------------------
const html = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
/** Every box of boxes, with its path ("1", "1.0" in a container) and the display conditions on it (its and its containers'): [box, path, conditions]. */
function allBoxes(boxes, prefix = '', conditions = []) {
const out = [];
boxes.forEach((box, i) => {
const path = prefix + i;
const c = box.condition && box.condition.trim() ? [...conditions, box.condition] : conditions;
out.push([box, path, c]);
out.push(...allBoxes(box.boxes || [], path + '.', c));
});
return out;
}
/**
* Whether a stage is interactive: its buttons send messages, run their programs and the stage
* goes on (contract boxes, chat; a button that does not leave; buttons with different programs).
* Otherwise its screen is a form, sent by its button, which ends the stage.
*/
function isInteractive(stage) {
const boxes = allBoxes(stage.active).map(([b]) => b);
if (boxes.some((b) => ['contractCreation', 'chat', 'plot'].includes(b.type) || (b.type === 'contractList' && b.buttons.length))) return true;
// An input into an array's element (CQ[1]): sent as a button's message (a form's fields are names).
if (boxes.some((b) => b.items.some((i) => i.kind === 'item' && i.input && i.variable && !/^\w+$/.test(i.variable)))) return true;
const buttons = boxes.flatMap((b) => b.buttons.map((button) => ({ box: b, button })));
if (buttons.some(({ box, button }) => !leavesStage(button, box, stage))) return true;
return buttons.filter(({ button }) => button.programs.length).length > 1;
}
/**
* A stage's screens as Vue templates (on the participant's page, where player, period and app
* are the participant's), and what they need from the server: the values the page cannot work
* out (table functions, layouts that are variables), lists' rows, boxes' display conditions,
* and, for an interactive stage, its buttons. z-Tree's placement of boxes is not kept: they
* follow one another.
*/
class Screens {
constructor(t, vars, tr, report) {
this.t = t;
this.vars = vars;
this.tr = tr;
this.report = report;
}
/** What a stage's screens collect: s. */
start(stageId, where, interactive, active, zstage = {}) {
// The inputs of the boxes without buttons: sent with a standard box's button.
const loose = allBoxes(active).filter(([b]) => ['standard', 'grid'].includes(b.type) && b.buttons.length === 0)
.flatMap(([b, path, conditions]) => b.items.filter((i) => i.kind === 'item' && i.input && i.variable).map((item) => ({ item, path, conditions })));
return {
stageId, where, interactive, loose, n: 0, zstage,
subject: { table: 'subjects', rec: 'player', outer: null },
form: { inputs: [], buttons: [] },
values: [], shows: [], lists: [], buttons: [], chats: [], plots: [],
};
}
/** A screen's boxes (the header first, if the stage shows it). */
screen(boxes, s, { header = null, prefix = '' } = {}) {
let out = header ? this.box(header, 'h', s) : '';
boxes.forEach((b, i) => { out += this.box(b, prefix + i, s); });
return out;
}
/** A value only the server can work out (JS of player, period, g): put in player.zt_.v. */
serverValue(s, code) {
const key = 'v' + (++s.n);
s.values.push(`${key}: (player, period, g) => ${code},`);
return `{{ player.zt_ && player.zt_.v.${key} }}`;
}
/** A subject's expression on the participant's page: player.X, period.globals.X; else null. */
vue(code) {
const vars = this.vars;
let node;
try {
node = lang.parseExpr(code);
} catch (err) {
return null;
}
const go = (n) => {
switch (n.k) {
case 'num': return String(n.v);
case 'str': return js(n.v).replace(/"/g, "'");
case 'var':
if (n.index != null || n.scope === 'outer') return null;
if (n.scope === 'globals' || (!vars.subjects.has(n.name) && vars.globals.has(n.name))) return `period.globals.${n.name}`;
return `player.${n.name}`;
case 'neg': { const e = go(n.e); return e == null ? null : `-(${e})`; }
case 'not': { const e = go(n.e); return e == null ? null : `!(${e})`; }
case 'bin': {
const l = go(n.l); const r = go(n.r);
if (l == null || r == null) return null;
const op = { '&': '&&', '|': '||', '==': '==', '!=': '!=', '^': '**' }[n.op] || n.op;
return `(${l} ${op} ${r})`;
}
case 'call':
if (n.fn === 'if') { const a = n.args.map(go); return a.includes(null) ? null : `(${a[0]} ? ${a[1]} : ${a[2]})`; }
if (['abs', 'sqrt', 'exp', 'min', 'max'].includes(n.fn)) { const a = n.args.map(go); return a.includes(null) ? null : `Math.${n.fn}(${a.join(', ')})`; }
return null;
default: return null;
}
};
return go(node);
}
/** A value shown with a layout ("1", "0.01", !text: ...): on the page if it can be, else from the server. */
shown(code, layout, s, where) {
const v = this.vue(code);
const plain = /^\s*(-?[\d.]+)?\s*$/.test(String(layout || ''));
if (v != null && plain) {
const step = String(layout || '').trim() === '' ? 1 : Number(layout);
const d = Math.max(0, -Math.floor(Math.log10(step > 0 ? step : 1) + 1e-9));
return `{{ ${v} | round(${d}) }}`;
}
const ctx = s.subject;
return this.serverValue(s, `z.fmt(${this.tr.exprOr(code, ctx, where, "''")}, ${this.tr.layout(layout, ctx, where)})`);
}
/** A label: its text, z-Tree's <var|layout> as values. */
label(text, s, where) {
let str = String(text || '');
if (str.startsWith('{\\rtf')) str = str.replace(/\\par[d]?/g, '\n').replace(/\{\\\*[^}]*\}/g, '').replace(/\\[a-z]+-?\d* ?/g, '').replace(/[{}]/g, '').trim();
if (!str.startsWith('<>')) return html(str).replace(/\n/g, '<br>');
// RTF made with values (GIMS's labels): worked out on the server as a whole.
if (/^\s*\{\\rtf/.test(str.slice(2))) return this.serverValue(s, this.tr.text(str, s.subject, where));
str = str.slice(2);
let out = '';
for (const part of textParts(str)) {
if (part.text != null) out += html(part.text).replace(/\n/g, '<br>');
else if (part.expr.trim()) out += this.shown(part.expr, part.layout, s, where);
}
return out;
}
/** An item: text, a value, or an input (named player.X in a form, X otherwise); bare: without its label (a grid's cell). */
item(item, s, where, inputs, bare = false) {
if (item.kind !== 'item') return '';
const label = this.label(item.label, s, where);
if (!item.variable) return bare ? label : ` <p class="zt-text">${label}</p>\n`;
if (!item.input) {
const value = this.shown(item.variable, item.layout, s, where);
return bare ? value : ` <p class="zt-item"><span class="zt-label">${label}</span> <span class="zt-value">${value}</span></p>\n`;
}
if (!/^\w+$/.test(item.variable) && !s.interactive) {
this.report.push({ status: 'todo', part: where, note: 'an input into ' + item.variable + ' (not a variable)' });
return '';
}
if (inputs) inputs.push(item);
const name = s.interactive ? item.variable : 'player.' + item.variable;
const l = parseLayout(item.layout, { rt: null });
let control;
if (l.kind === 'radio' || l.kind === 'text' || l.kind === 'button') {
control = l.options.map(([v, t]) => `<label class="zt-choice"><input type="radio" name="${name}" value="${v}"> ${html(t)}</label>`).join(' ');
} else if (l.kind === 'radioline' && l.options.length >= 2) {
const [lo, hi] = [l.options[0], l.options[l.options.length - 1]];
const n = l.count || 2;
const values = Array.from({ length: n }, (_, k) => lo[0] + (hi[0] - lo[0]) * k / (n - 1));
control = `${html(lo[1])} ${values.map((v) => `<input type="radio" name="${name}" value="${v}">`).join(' ')} ${html(hi[1])}`;
} else if (l.kind === 'checkbox') {
const [v, t] = l.options[0] || [1, ''];
control = `<label class="zt-choice"><input type="checkbox" name="${name}" value="${v}"> ${html(t)}</label>`;
} else if (l.kind === 'slider' || l.kind === 'scrollbar') {
control = `<input type="range" name="${name}" min="${l.options[0] ? l.options[0][0] : 0}" max="${l.options[1] ? l.options[1][0] : 100}">`;
} else if (s.interactive) {
control = `<input type="text" inputmode="decimal" autocomplete="off" name="${name}">`;
} else {
control = `<input type="number" name="${name}" step="${l.step}">`;
}
return bare ? control : ` <p class="zt-item"><span class="zt-label">${label}</span> <span class="zt-input">${control}</span></p>\n`;
}
/** A box's buttons: in a form, they send it; else each is one of the stage's buttons (stage.buttons). */
buttons(box, path, s, where, inputs) {
if (box.buttons.length === 0) return '';
if (!s.interactive) {
s.form.buttons.push(...box.buttons.map((b) => ({ ...b, box })));
return ' <p class="zt-buttons">' + box.buttons.map((b) => `<button${b.color ? ` style="background:${html(b.color)}"` : ''}>${html(b.name)}</button>`).join(' ') + '</p>\n';
}
box.buttons.forEach((button, i) => s.buttons.push(this.button(box, path, button, i, s, where, inputs)));
return ' <p class="zt-buttons">' + box.buttons.map((b, i) => `<button type="button" class="zt-button" data-zt-button="${i}"${b.color ? ` style="background:${html(b.color)}"` : ''}>${html(b.name)}</button>`).join(' ') + '</p>\n';
}
/**
* One of an interactive stage's buttons, as JS: what it is pressed on (the subject, a new
* record, or the chosen row of a list), its inputs, its checkers, its programs, whether it
* leaves the stage. Its functions have player, rec (the record), period and g (globals).
*/
button(box, path, button, i, s, where, inputs) {
const w = where + ', button ' + button.name;
const tr = this.tr;
const kind = box.type === 'contractCreation' ? 'new' : box.type === 'contractList' ? 'list' : 'subject';
const table = kind === 'subject' ? 'subjects' : (box.table || 'contracts');
const ctx = kind === 'subject' ? s.subject : { table, rec: 'rec', outer: s.subject };
const leaves = leavesStage(button, box, s.zstage);
const fn = (body) => `(player, rec, period, g) => ${body}`;
const lines = [`// Box ${js(box.name || box.type)}, button ${js(button.name)}: ` +
{ new: `a new ${table} record`, list: `the chosen ${table} record`, subject: 'the subject' }[kind] + (leaves ? '; it ends the stage.' : '.')];
for (const l of button.loaders || []) this.report.push({ status: 'todo', part: w, note: 'a table loader (' + l.file + ') is not converted; the treatment runs it as it is' });
for (const d of button.dumpers || []) this.report.push({ status: 'todo', part: w, note: 'a table dumper (' + d.file + ') is not converted; the treatment runs it as it is' });
lines.push(`${js('b' + path + '/' + i)}: {`);
lines.push(` kind: ${js(kind)}, table: ${js(table)}, vars: VARS[${js(table)}] || [], leaves: ${leaves},`);
// Inputs: the box's; a standard box's button also sends those of the boxes without buttons.
const all = (inputs || []).map((item) => ({ item, path: null }));
if (kind === 'subject') all.push(...s.loose.filter((x) => x.path !== path));
lines.push(' inputs: [');
for (const { item, path: from, conditions } of all) {
const l = parseLayout(item.layout, { rt: null });
const literal = (code) => /^-?[\d.]+$/.test(String(code).trim());
const bound = (code) => (literal(code) ? `() => ${Number(code)}` : fn(tr.exprOr(code, ctx, w, '0')));
const stepText = String(item.layout || '').trim();
const step = l.kind !== 'number' ? 'null' : stepText === '' ? '1' : literal(stepText) ? String(Number(stepText)) : fn(tr.exprOr(stepText, ctx, w, '1'));
const parts = [`name: ${js(item.variable)}`, `label: ${js(String(item.label || item.variable).replace(/^<>/, '').replace(/<[^>]*>/g, ''))}`];
// An array's element (CQ[1]): read and set as the program would.
if (!/^\w+$/.test(item.variable)) {
try {
const target = lang.parse(item.variable + ' = 0;').body[0].target;
const c = { ...ctx, where: w, code: item.variable };
parts.push(`get: ${fn(tr.expr(lang.parseExpr(item.variable), c))}`);
parts.push(`set: (player, rec, period, g, v) => { ${tr.statement({ k: 'set', target, value: { k: 'js', code: 'v' } }, c).join(' ')} }`);
} catch (err) {
this.report.push({ status: 'todo', part: w, note: 'an input into ' + item.variable + ': ' + err.message });
}
}
if (from != null) parts.push(`box: ${js('b' + from)}`);
// Another box's input: asked for only while that box is shown.
if (conditions && conditions.length) parts.push(`shown: ${fn(conditions.map((c) => `(${tr.exprOr(c, s.subject, w, 'true')})`).join(' && '))}`);
if (item.min) parts.push(`min: ${bound(item.min)}`);
if (item.max) parts.push(`max: ${bound(item.max)}`);
parts.push(`step: ${step}`, `numeric: ${l.kind === 'number'}`);
if (l.kind === 'checkbox') parts.push('checkbox: true');
lines.push(` { ${parts.join(', ')} },`);
}
lines.push(' ],');
// Checkers: the box's, then the button's.
const checkers = [...box.items.filter((x) => x.kind === 'checker'), ...button.checkers];
if (checkers.length) {
lines.push(' check(player, rec, period, g) {');
for (const c of checkers) lines.push(` if (!(${tr.exprOr(c.condition, ctx, w, 'true')})) return ${tr.text(c.message || 'Not allowed.', ctx, w)};`);
lines.push(' return null;', ' },');
}
// Programs: on the box's record for its table, the subject's, the globals', or each of another table's.
if (button.programs.length) {
lines.push(' run(player, rec, period, g) {');
for (const p of button.programs) {
const pt = p.table || 'subjects';
let code;
if (pt === table && kind !== 'subject') code = tr.programOn(p, w, ctx);
else if (pt === 'subjects') code = tr.programOn(p, w, s.subject);
else if (pt === 'globals') code = tr.programOn(p, w, { table: 'globals', rec: 'g', outer: s.subject });
else {
const r = 'r' + (++tr.n);
code = tr.programOn(p, w, { table: pt, rec: r, outer: s.subject }, tr.records(pt).code);
}
lines.push(...code.map((l) => ' ' + l));
}
lines.push(' },');
}
lines.push('},');
this.report.push({ status: 'converted', part: w, note: { new: 'makes a ' + table + ' record', list: 'on the chosen ' + table + ' record', subject: 'on the subject' }[kind] });
return lines.join('\n');
}
/** A contract list: its table's records that meet its condition, sorted, worked out on the server (stage.lists). */
list(box, path, s, where) {
const tr = this.tr;
const table = box.table || 'contracts';
const ctx = { table, rec: 'rec', outer: s.subject };
const items = box.items.filter((x) => x.kind === 'item');
const cond = box.tableCondition ? tr.exprOr(box.tableCondition, ctx, where, 'false') : null;
const keys = String(box.sorting || '').split(';').map((k) => k.trim()).filter(Boolean)
.map((k) => `[(rec) => ${tr.exprOr(k.replace(/^[-+]\s*/, ''), ctx, where, '0')}, ${k.startsWith('-')}]`);
const key = 'b' + path;
const lines = [`// Box ${js(box.name || box.type)}: the ${table} records${box.tableCondition ? ' where ' + box.tableCondition.trim() : ''}${box.sorting ? ', by ' + box.sorting.trim() : ''}.`,
`${js(key)}: {`,
` rows: (player, period, g) => z.sort(z.table(period, ${js(table)})${cond ? `.filter((rec) => ${cond})` : ''}, [${keys.join(', ')}]),`,
` cells: (rec, player, period, g) => [${items.map((it) => (it.variable ? `z.fmt(${tr.exprOr(it.variable, ctx, where, "''")}, ${tr.layout(it.layout, ctx, where)})` : js(''))).join(', ')}],`];
if (box.owner) lines.push(` own: (rec, player) => rec.${box.owner.trim()} === player.Subject,`);
lines.push('},');
s.lists.push(lines.join('\n'));
const choose = box.buttons.length > 0;
// An input item: an input in each row, for the chosen row's record (its buttons take it).
const cell = (it, k) => (it.input && it.variable && choose
? `<td><input type="text" inputmode="decimal" autocomplete="off" class="zt-row-input" name="${html(it.variable)}"></td>`
: `<td>{{ row.cells[${k}] }}</td>`);
let out = `<table class="zt-list">\n <thead><tr>${choose ? '<th></th>' : ''}${items.map((it) => `<th>${this.label(it.label, s, where)}</th>`).join('')}</tr></thead>\n`;
out += ` <tbody><tr v-for="row in (player.zt_ ? player.zt_.lists['${key}'] : [])" :key="row.id" :class="{ 'zt-own': row.own }">` +
(choose ? `<td><input type="radio" name="zt-row-${key}" :value="row.id"></td>` : '') +
items.map(cell).join('') + '</tr></tbody>\n</table>\n';
return out;
}
/**
* A chat box: its table's entries that meet its condition, each in its format (worked out on
* the server, as a list's rows), and an input for a new entry (stage.chats: the entry's
* table and variable, and the box's programs, run on it).
*/
chat(box, path, s, where) {
const tr = this.tr;
const table = box.table || 'contracts';
const ctx = { table, rec: 'rec', outer: s.subject };
const key = 'b' + path;
let out = '';
if (box.outputs && box.outputs.length) {
let cond = box.condition2 && box.condition2.trim() ? tr.exprOr(box.condition2, ctx, where, 'false') : null;
if (cond === '1' || cond === 'true') cond = null;
// <|-1>: the entry's text.
const format = box.outputs[0].replace(/<\|-1>/g, '<' + (box.variable || 'Text') + '|-1>');
s.lists.push([`// Box ${js(box.name || box.type)}: the chat's entries${box.condition2 ? ' where ' + box.condition2.trim() : ''}.`,
`${js(key)}: {`,
` rows: (player, period, g) => z.table(period, ${js(table)})${cond ? `.filter((rec) => ${cond})` : ''},`,
` cells: (rec, player, period, g) => [${tr.text(format.startsWith('<>') ? format : '<>' + format, ctx, where)}],`,
'},'].join('\n'));
out += `<div class="zt-chat-log"><div v-for="row in (player.zt_ ? player.zt_.lists['${key}'] : [])" :key="row.id" class="zt-chat-line">{{ row.cells[0] }}</div></div>\n`;
}
if (box.variable) {
const run = box.programs.flatMap((p) => tr.programOn(p, where, (p.table || table) === table ? ctx : { table: p.table, rec: 'g', outer: s.subject }));
s.chats.push([`// Box ${js(box.name || box.type)}: an entry is a new ${table} record, its text in ${box.variable}.`,
`${js(key)}: {`,
` table: ${js(table)}, variable: ${js(box.variable)}, vars: VARS[${js(table)}] || [],`,
...(run.length ? [' run(player, rec, period, g) {', ...run.map((l) => ' ' + l), ' },'] : []),
'},'].join('\n'));
out += '<input type="text" class="zt-chat-input" autocomplete="off">\n';
}
this.report.push({ status: 'converted', part: where, note: 'chat box' });
return out;
}
/** A history box: the subject's records of the periods before, a row each (worked out on the server, as a list's rows). */
history(box, path, s, where) {
const tr = this.tr;
const ctx = { table: 'subjects', rec: 'rec', outer: null };
const items = box.items.filter((x) => x.kind === 'item');
const key = 'b' + path;
s.lists.push([`// Box ${js(box.name || box.type)}: the subject's records of the periods before.`,
`${js(key)}: {`,
' rows: (player, period, g) => z.history(player),',
` cells: (rec, player, period, g) => [${items.map((it) => (it.variable ? `z.fmt(${tr.exprOr(it.variable, ctx, where, "''")}, ${tr.layout(it.layout, ctx, where)})` : js(''))).join(', ')}],`,
'},'].join('\n'));
this.report.push({ status: 'converted', part: where, note: 'history box' });
return `<table class="zt-list">\n <thead><tr>${items.map((it) => `<th>${this.label(it.label, s, where)}</th>`).join('')}</tr></thead>\n` +
` <tbody><tr v-for="row in (player.zt_ ? player.zt_.lists['${key}'] : [])" :key="row.id"><td v-for="(cell, k) in row.cells" :key="k">{{ cell }}</td></tr></tbody>\n</table>\n`;
}
/** A grid box: its items in columns of equal length (the first holds the rows' labels), as z-Leaf shows it. */
grid(box, s, where, inputs) {
const items = box.items.filter((it) => it.kind === 'item');
let rowsPerColumn = items.length;
for (let r = 2; r < items.length; r++) {
if (items.length % r === 0 && items.slice(0, r).every((it) => !it.variable) && !items[r].variable && items[r + 1] && items[r + 1].variable) {
rowsPerColumn = r;
break;
}
}
const columns = [];
for (let i = 0; i < items.length; i += rowsPerColumn) columns.push(items.slice(i, i + rowsPerColumn));
let out = '<table class="zt-grid">\n';
for (let r = 0; r < rowsPerColumn; r++) {
out += ' <tr>' + columns.map((col) => `<td>${col[r] ? this.item(col[r], s, where, inputs, true) : ''}</td>`).join('') + '</tr>\n';
}
this.report.push({ status: 'converted', part: where, note: 'grid box' });
return out + '</table>\n';
}
/**
* A plot box (stage.plots): draw, its items as shapes in the plot's coordinates for the
* subject (graphs' items for each of their table's records), as the z-Tree dialect draws
* them; and inputs, what each plot input does (by its place in the box: "1", "2.0", "2:0"
* the first input of item 2), given the records it was drawn for (innermost first).
*/
plot(box, path, s, where) {
const tr = this.tr;
const key = 'b' + path;
const has = (code) => code != null && String(code).trim() !== '';
// A number (fallback if not given), a colour (null if not given), in ctx.
const num = (code, ctx, fallback = 0) => (!has(code) ? String(fallback)
: /^-?[\d.]+$/.test(String(code).trim()) ? String(Number(code)) : `z.num(${tr.exprOr(code, ctx, where, String(fallback))})`);
const color = (code, ctx) => (!has(code) ? 'null' : `z.color(${tr.exprOr(code, ctx, where, 'null')})`);
const cond = (code, ctx) => (has(code) ? tr.exprOr(code, ctx, where, 'false') : null);
const draw = [];
const inputs = [];
// The records a level is drawn for, as JS: [{table, id}], innermost first.
const recsOf = (chain) => '[' + chain.slice().reverse().map((c) => `{ table: ${js(c.table)}, id: z.idOf(${c.rec}) }`).join(', ') + ']';
const idOf = (id, chain) => `${js(id + '@')} + z.ids(${recsOf(chain)})`;
/** What an input does, as JS (in inputs): chain is the graphs' records around it. */
const handler = (inp, id, chain) => {
const inner = chain.length ? chain[chain.length - 1] : s.subject;
const table = inp.table || 'subjects';
const target = inp.action === 0 ? (table === 'subjects' ? s.subject : { table, rec: 'rec', outer: inner })
: (chain.slice().reverse().find((c) => c.table === table) || inner);
const unpack = chain.length ? [`const [${chain.slice().reverse().map((c) => c.rec).join(', ')}] = recs;`] : [];
const vars = {};
for (const [k, v] of [['x', inp.xVar], ['y', inp.yVar], ['x0', inp.x0Var], ['y0', inp.y0Var], ['x1', inp.x1Var], ['y1', inp.y1Var]]) if (/^\w+$/.test(v || '')) vars[k] = v;
const lines = [`${js(id)}: {`, ` action: ${inp.action || 0}, table: ${js(table)}, tableVars: VARS[${js(table)}] || [], vars: ${JSON.stringify(vars).replace(/"(\w+)":/g, '$1: ')},`];
const c = cond(inp.condition, inner);
if (c) lines.push(` ok(player, period, g, recs) {`, ...unpack.map((l) => ' ' + l), ` return ${c};`, ' },');
lines.push(' run(player, rec, period, g, recs, coords) {', ...unpack.map((l) => ' ' + l), ` Object.assign(${target.rec}, coords);`);
for (const p of inp.programs || []) {
const pt = p.table || 'subjects';
let pc = pt === target.table ? target : chain.slice().reverse().find((x) => x.table === pt);
if (pc == null) {
if (pt === 'subjects') pc = s.subject;
else if (pt === 'globals') pc = { table: 'globals', rec: 'g', outer: inner };
else {
this.report.push({ status: 'todo', part: where, note: 'a plot input\'s program on ' + pt + ', which it is not drawn for' });
continue;
}
}
// A drag's start and end are the program's record's too (its programs read them there).
if (inp.action === 2 && pc !== target) lines.push(` Object.assign(${pc.rec}, coords);`);
lines.push(...tr.programOn(p, where + ', plot input', pc).map((l) => ' ' + l));
}
lines.push(' },', '},');
inputs.push(lines.join('\n'));
};
/** Lines drawing items at a level: ctx their record's, chain the graphs' records, collected the JS array of the level's inputs. */
const visit = (items, prefix, ctx, chain, collected, ind) => {
const out = [];
const at = (l) => out.push(ind + l);
items.forEach((item, i) => {
const id = prefix + i;
if (item.kind === 'graph') {
const q = 'q' + (chain.length + 1);
const t = item.table || 'globals';
const inner = { table: t, rec: q, outer: ctx };
const gc = cond(item.graphCondition, inner);
const level = 'level' + (chain.length + 1);
const joined = 'joined' + (chain.length + 1);
at(`// A graph: its items for each ${t} record${has(item.graphCondition) ? ' where ' + item.graphCondition.trim() : ''}.`);
at('{');
at(` const ${joined} = [];`);
at(` for (const ${q} of ${tr.records(t).code}${gc && gc !== '1' ? `.filter((${q}) => ${gc})` : ''}) {`);
at(` const ${level} = [];`);
at(' const before = plot.shapes.length;');
out.push(...visit(item.items || [], id + '.', inner, [...chain, inner], level, ind + ' '));
at(` plot.shapes.slice(before).forEach((sh) => sh.inputs && sh.inputs.push(...${level}));`);
at(` plot.shapes.slice(before).filter((sh) => sh.kind === 'point').forEach((sh) => ${joined}.push([sh.x, sh.y]));`);
at(' }');
at(` plot.joined(${joined}, ${color(item.lineColor, ctx)}, ${num(item.lineWidth, ctx, 1)});`);
at('}');
return;
}
if (item.kind === 'input') {
// In a graph: its record's items take it; in the box: a click anywhere.
handler(item, id, chain);
const c = cond(item.condition, ctx);
at(`${c ? `if (${c}) ` : ''}${collected}.push(plot.input(${idOf(id, chain)}, ${item.action || 0}));`);
return;
}
const c = cond(item.condition, ctx);
const props = [`kind: ${js(item.kind)}`, 'inputs: []'];
const p = (k, v) => props.push(`${k}: ${v}`);
switch (item.kind) {
case 'point':
p('x', num(item.x, ctx)); p('y', num(item.y, ctx)); p('size', num(item.size, ctx, 5)); p('corners', num(item.corners, ctx, 4));
p('rotation', num(item.rotation, ctx)); p('spokes', !!item.spokes); p('color', color(item.lineColor, ctx)); p('width', num(item.lineWidth, ctx, 1));
p('fill', color(item.fillColor, ctx));
break;
case 'line':
p('x1', num(item.x1, ctx)); p('y1', num(item.y1, ctx)); p('x2', num(item.x2, ctx)); p('y2', num(item.y2, ctx));
// An arrow's start: none if not given (0, given: a bar).
p('arrowStart', has(item.arrowStart) ? num(item.arrowStart, ctx) : 'null'); p('arrowWidth', num(item.arrowWidth, ctx)); p('arrowEnd', has(item.arrowEnd) ? num(item.arrowEnd, ctx) : 'null');
p('arrowFilled', !!item.arrowFilled); p('color', `${color(item.lineColor, ctx)} || 'rgb(0,0,0)'`); p('width', num(item.lineWidth, ctx, 1));
break;
case 'rect': {
// Each axis from its two edges, an edge and the size, or the centre and the size.
const axis = (lo, hi, center, size) => {
if (has(lo) && has(hi)) return `[${num(lo, ctx)}, ${num(hi, ctx)}]`;
if (has(lo)) return `z.span(${num(lo, ctx)}, ${num(size, ctx)}, 0)`;
if (has(hi)) return `z.span(${num(hi, ctx)}, ${num(size, ctx)}, 1)`;
return `z.span(${num(center, ctx)}, ${num(size, ctx)}, 0.5)`;
};
props.push(`...z.rect(${axis(item.xLeft, item.xRight, item.xCenter, item.width)}, ${axis(item.yBottom, item.yTop, item.yCenter, item.height)})`);
p('color', color(item.lineColor, ctx)); p('width', num(item.lineWidth, ctx, 1)); p('fill', color(item.fillColor, ctx));
break;
}
case 'pie':
p('x', num(item.x, ctx)); p('y', num(item.y, ctx)); p('rx', num(item.rx, ctx, 1)); p('ry', num(item.ry, ctx, 1));
p('a1', num(item.angleStart, ctx)); p('a2', num(item.angleEnd, ctx, 360)); p('color', color(item.lineColor, ctx)); p('width', num(item.lineWidth, ctx, 1));
p('fill', color(item.fillColor, ctx));
break;
case 'text':
p('x', num(item.x, ctx)); p('y', num(item.y, ctx)); p('text', tr.text(item.text, ctx, where));
p('hAlign', JSON.stringify(item.hAlign)); p('vAlign', JSON.stringify(item.vAlign)); p('color', `${color(item.color, ctx)} || 'rgb(0,0,0)'`); p('size', num(item.fontSize, ctx, 12));
break;
case 'axis':
p('horizontal', !!item.horizontal); p('position', num(item.position, ctx)); p('from', num(item.from, ctx)); p('to', num(item.to, ctx, 1));
p('tick', num(item.tickStep, ctx)); p('label', num(item.labelStep, ctx)); p('caption', js(item.caption || ''));
p('color', `${color(item.lineColor, ctx)} || 'rgb(0,0,0)'`); p('width', num(item.lineWidth, ctx, 1));
p('layout', tr.layout(item.labelLayout || '1', ctx, where));
break;
default:
this.report.push({ status: 'todo', part: where, note: 'a plot item of kind ' + item.kind });
return;
}
at(`${c ? `if (${c}) ` : ''}{`);
at(` const shape = plot.add({ ${props.join(', ')} }, ${recsOf(chain)});`);
(item.inputs || []).forEach((inp, k) => {
handler(inp, id + ':' + k, chain);
const ic = cond(inp.condition, ctx);
at(` ${ic ? `if (${ic}) ` : ''}shape.inputs.push(plot.input(${idOf(id + ':' + k, chain)}, ${inp.action || 0}));`);
});
at('}');
});
return out;
};
const r = box.range || {};
draw.push(`plot.range(${num(r.xLeft, s.subject)}, ${num(r.xRight, s.subject, 100)}, ${num(r.yBottom, s.subject)}, ${num(r.yTop, s.subject, 100)}, ${box.keepAspect === true});`);
draw.push(...visit(box.plotItems || [], '', s.subject, [], 'plot.boxInputs', ''));
s.plots.push([`// Box ${js(box.name || box.type)}.`, `${js(key)}: {`,
' draw(plot, player, period, g) {', ...draw.map((l) => ' ' + l), ' },',
' inputs: {', ...inputs.join('\n').split('\n').filter(Boolean).map((l) => ' ' + l), ' },',
'},'].join('\n'));
this.report.push({ status: 'converted', part: where, note: 'plot box' });
return `<div class="ztree-plot" data-box="${key}" :data-plot="player.zt_ && player.zt_.plots['${key}']"></div>\n`;
}
/** A box: its display condition (if any), then its items, rows, inputs and buttons. */
box(box, path, s) {
const where = s.where + ', box ' + (box.name || box.type);
const key = 'b' + path;
let cond = '';
if (box.condition && box.condition.trim()) {
const v = this.vue(box.condition);
if (v != null) cond = ` v-if="${html(v)}"`;
else {
s.shows.push(`${js(key)}: (player, period, g) => ${this.tr.exprOr(box.condition, s.subject, where, 'true')},`);
cond = ` v-if="!player.zt_ || player.zt_.show['${key}']"`;
}
}
const open = (cls, extra = '') => `<div class="zt-box ${cls}" data-zt-box="${key}"${extra}${cond}>\n`;
switch (box.type) {
case 'container':
return open('zt-container') + this.screen(box.boxes, s, { prefix: path + '.' }) + '</div>\n';
case 'header':
return `<div class="zt-header"${cond}>Period {{ period.id }} of ${Math.max(1, this.t.periods.length)}` +
' <span v-if="hasTimeoutClient">Remaining time [sec]: {{ Math.ceil(timeLeftClient / 1000) }}</span></div>\n';
case 'help':
return `<div class="zt-help"${cond}><b>${html(box.title)}</b><br>${html(box.text).replace(/\n/g, '<br>')}</div>\n`;
case 'standard':
case 'contractCreation': {
const inputs = [];
const loose = s.interactive && box.buttons.length === 0 && box.items.some((i) => i.kind === 'item' && i.input);
let out = open(box.type === 'standard' ? 'zt-standard' : 'zt-creation', loose ? ' data-zt-loose' : '');
for (const item of box.items) out += this.item(item, s, where, inputs);
if (!s.interactive) s.form.inputs.push(...inputs);
out += this.buttons(box, path, s, where, inputs);
return out + '</div>\n';
}
case 'contractList':
return open('zt-list-box') + this.list(box, path, s, where) + this.buttons(box, path, s, where, box.items.filter((it) => it.kind === 'item' && it.input && it.variable)) + '</div>\n';
case 'chat':
return open('zt-chat') + this.chat(box, path, s, where) + '</div>\n';
case 'history':
return open('zt-history') + this.history(box, path, s, where) + '</div>\n';
case 'grid': {
const inputs = [];
const loose = s.interactive && box.buttons.length === 0 && box.items.some((i) => i.kind === 'item' && i.input);
let out = open('zt-grid-box', loose ? ' data-zt-loose' : '') + this.grid(box, s, where, inputs);
if (!s.interactive) s.form.inputs.push(...inputs);
out += this.buttons(box, path, s, where, inputs);
return out + '</div>\n';
}
case 'plot':
return open('zt-plot') + this.plot(box, path, s, where) + this.buttons(box, path, s, where, []) + '</div>\n';
case 'calculator':
// As in the z-Tree dialect: an empty box (z-Leaf's calculator is not offered).
this.report.push({ status: 'converted', part: where, note: 'an empty box: z-Leaf\'s calculator is not offered' });
return open('zt-calculator') + '</div>\n';
default:
this.report.push({ status: 'todo', part: where, note: 'a ' + box.type + ' box is not converted (it runs in the z-Tree dialect)' });
return `<!-- TODO: the ${html(box.type)} box ${html(box.name)} -->\n`;
}
}
}
// --- The app ----------------------------------------------------------------------------------
/** app.jtt for treatment t, and the report: [{status, part, note}]. */
function convertTreatment(t, name) {
const report = [];
const vars = variables(t);
const arrays = arrayDeclarations(t);
const tr = new Translator(vars, report, arrays);
const screens = new Screens(t, vars, tr, report);
let interactiveStages = 0;
// The inputs of each stage's form, by stage: in app.fields, unless another stage's differ (then in stage.fields).
const stageFields = {};
const out = [];
const w = (s = '') => out.push(s);
const numSubjects = Math.max(1, t.subjects.length);
w(`// ${name}: converted by jtree from the z-Tree treatment ${name}.ztt (see CONVERSION.md).`);
w('// z-Tree\'s tables are jtree\'s: subjects the players, globals period.globals, contracts');
w('// period.contracts, summary app.summary; Profit is points.');
w();
w("const z = require(path.join(path.dirname(app.appPath), 'ztree.cjs'));");
w();
w(`app.title = ${js(name)};`);
w(`app.numPeriods = ${Math.max(1, t.periods.length)};`);
w(`app.suggestedNumParticipants = ${numSubjects};`);
w();
w('// The tables\' variables, set to 0 in new records.');
w('const VARS = {');
for (const [table, names] of Object.entries(vars)) w(` ${table}: ${js([...names])},`);
w('};');
w();
// Tables' arrays, tables that last, new records' programs.
const declared = Object.entries(arrays).filter(([, names]) => Object.keys(names).length);
if (declared.length) {
w('// Arrays declared for each table (array X[n]; in any of its programs, even one that does not run):');
w('// made in a record when it first uses them.');
w('app.ztArrays = {');
for (const [table, names] of declared) {
w(` ${js(table)}: {`);
for (const [name, dims] of Object.entries(names)) {
const ctx = { table, rec: 'rec', outer: null, where: 'Arrays', code: '' };
const d = dims.map((x) => { try { return tr.expr(x, ctx); } catch (err) { return '1'; } });
w(` ${js(name)}: (rec, period, g) => ${d.length === 1 ? `z.array(1, ${d[0]})` : `z.array(${d.join(', ')})`},`);
}
w(' },');
}
w('};');
w();
}
const lasting = (t.tables || []).filter((x) => /^(TREATMENT|SESSION)$/i.test(x.lifetime || '') && !['globals', 'subjects', 'summary', 'contracts', 'session', 'clients', 'treatments', 'sessionglobals', 'participations', 'logfile'].includes(x.name)).map((x) => x.name);
if (lasting.length) {
w('// Tables that last the treatment (or the session): their records are kept from period to period.');
w(`app.ztLasting = ${js(lasting)};`);
w();
}
const initTables = [...new Set(t.background.programs.map((p) => p.table || 'subjects'))].filter((x) => !['subjects', 'globals', 'summary', 'session'].includes(x));
if (initTables.length) {
w('// A new record of a table gets the background\'s programs for it, as in z-Tree.');
w('app.ztInits = {');
for (const table of initTables) {
w(` ${js(table)}(rec, period, g) {`);
for (const p of t.background.programs.filter((x) => (x.table || 'subjects') === table)) {
w(indent(tr.programOn(p, 'Background, new ' + table + ' records', { table, rec: 'rec', outer: null }).join('\n'), ' '));
}
w(' },');
}
w('};');
w();
}
// The parameter table.
w('// The parameter table: each subject\'s group, by period and subject (z-Tree\'s Group).');
const groups = t.periods.map((_, p) => Array.from({ length: numSubjects }, (__, i) => {
const cell = t.params[p * numSubjects + i];
return cell && cell.group != null ? cell.group : 1;
}));
w(`const GROUPS = ${js(groups.length ? groups : [[1]])};`);
w();
w('/** The parameter table\'s programs for a period: the period\'s, then each subject\'s and its cell\'s. */');
w('function parameters(period, players) {');
w(' const g = z.globals(period);');
const periodPrograms = t.periods.map((pp, p) => [p, pp.program]).filter(([, c]) => c);
for (const [p, code] of periodPrograms) {
w(` if (period.id === ${p + 1}) {`);
w(indent(tr.program({ table: 'globals', code }, 'Parameter table, period ' + (p + 1)).join('\n'), ' '));
w(' }');
}
const cells = [];
t.subjects.forEach((s, i) => { if (s.program) cells.push([`player.Subject === ${i + 1}`, s.program, 'subject ' + s.name]); });
t.params.forEach((c, k) => {
if (c.program) cells.push([`period.id === ${Math.floor(k / numSubjects) + 1} && player.Subject === ${(k % numSubjects) + 1}`, c.program, 'period ' + (Math.floor(k / numSubjects) + 1) + ', subject ' + ((k % numSubjects) + 1)]);
});
if (cells.length) w(' for (const player of players) {');
for (const [cond, code, where] of cells) {
w(` if (${cond}) {`);
const lines = tr.program({ table: 'subjects', code }, 'Parameter table, ' + where, '[player]');
// A loop over [player] of one: its body.
w(indent(lines.slice(2, -1).map((l) => l.replace(/^ {4}/, '')).join('\n'), ' '));
w(' }');
}
if (cells.length) w(' }');
w('}');
w();
// Periods.
w('app.periodStart = function (period) {');
w(' const order = Object.keys(app.session.participants);');
w(' const players = period.groups.flatMap((group) => group.players);');
w(' const before = z.before(period);');
w(' for (const player of players) {');
w(' const subject = order.indexOf(String(player.participant.id)) + 1;');
w(' const last = before == null ? null : z.subjects(before).find((p) => p.participant === player.participant);');
w(' Object.assign(player, { Period: period.id, Subject: subject, Group: (GROUPS[period.id - 1] || GROUPS[0])[(subject - 1) % ' + numSubjects + '],');
w(' Profit: 0, TotalProfit: last == null ? 0 : last.TotalProfit, Participate: 1 });');
w(' z.init(player, VARS.subjects);');
w(' }');
w(' const g = z.init(Object.assign(z.globals(period), { Period: period.id, NumPeriods: app.numPeriods, RepeatTreatment: 0 }), VARS.globals);');
w(' z.summary(app)[period.id - 1] = z.init({ Period: period.id }, VARS.summary);');
if (t.background.programs.length) w(' // The background\'s programs.');
for (const p of t.background.programs) w(indent(tr.program(p, 'Background').join('\n'), ' '));
w(' parameters(period, z.subjects(period));');
w('};');
w();
w('// At the end of each period: Profit is the period\'s points, added to TotalProfit.');
w('app.periodEnd = function (period) {');
w(' for (const player of z.subjects(period)) {');
w(' player.TotalProfit += player.Profit;');
w(' player.points = player.Profit;');
w(' }');
w(' z.endPeriod(period);');
w('};');
report.push({ status: 'converted', part: 'Periods and parameter table', note: t.periods.length + ' period(s), ' + numSubjects + ' subject(s)' });
if (t.background.programs.length) report.push({ status: 'converted', part: 'Background', note: t.background.programs.length + ' program(s)' });
// The waiting screen between stages: the background's.
const bg = screens.start(null, 'Background, waiting screen', false, []);
const waiting = screens.screen(t.background.waiting, bg);
if (bg.values.length || bg.shows.length || bg.lists.length) {
report.push({ status: 'todo', part: 'Background, waiting screen', note: 'values only the server can work out (table functions) are not shown on it' });
}
if (waiting) {
w();
w('// The waiting screen (the background\'s).');
w('app.waitingScreen = `\n' + waiting.replace(/`/g, '\\`') + '`;');
}
// Stages.
t.stages.forEach((stage, i) => {
const where = 'Stage ' + stage.name;
const before = report.filter((x) => x.status === 'todo').length;
const waitForAll = stage.startIfPossible !== true;
const interactive = isInteractive(stage);
w();
w(`// Stage ${i + 1}: ${stage.name}${waitForAll ? ' (waits for all subjects)' : ' (starts as each subject gets there)'}.`);
w('{');
w(` const stage = app.newStage(${js('stage' + (i + 1))});`);
w(` stage.title = ${js(stage.name)};`);
w(` z.stage(stage, { waitForAll: ${waitForAll}${interactive ? ', interactive: true' : ''} });`);
const programs = stage.programs.map((p) => tr.program(p, where).join('\n')).join('\n');
if (waitForAll) {
w(' // When all subjects are there: Participate, then the stage\'s programs.');
w(' stage.allGroupsStart = function (period) {');
w(' const g = z.globals(period);');
w(' const players = z.subjects(period);');
w(' for (const player of players) player.Participate = 1;');
if (programs) w(indent(programs, ' '));
w(' };');
w(' stage.canPlayerParticipate = (player) => player.Participate !== 0;');
} else {
w(' // As each subject gets here: Participate, then the stage\'s programs (globals\' once).');
w(' const started = new WeakSet();');
w(' stage.canPlayerParticipate = function (player) {');
w(' if (!started.has(player)) {');
w(' started.add(player);');
w(' const period = player.group.period;');
w(' const g = z.globals(period);');
w(' const players = [player];');
w(' player.Participate = 1;');
const once = stage.programs.map((p) => {
const lines = tr.program(p, where).join('\n');
return p.table === 'globals' ? `if (!(g.started_ || (g.started_ = {}))[stage.id + '/' + ${js(p.code.length)}]) {\n g.started_[stage.id + '/' + ${js(p.code.length)}] = true;\n${indent(lines, ' ')}\n}` : lines;
}).join('\n');
if (once) w(indent(once, ' '));
w(' z.changed(period);');
w(' }');
w(' return player.Participate !== 0;');
w(' };');
}
if (stage.timeout && stage.timeout.trim() !== '' && stage.timeout.trim() !== '-1') {
w(` // Timeout: ${stage.timeout.trim()} seconds.`);
w(` stage.getClientDuration = (player) => { const period = player.group.period; const g = z.globals(period); return Math.max(0, ${tr.subjectExpr(stage.timeout, where)}); };`);
}
// Screens.
const s = screens.start('stage' + (i + 1), where, interactive, stage.active, stage);
// Table loaders and dumpers (files the treatment reads and writes): run in the dialect, not in converted apps.
for (const l of stage.loaders || []) report.push({ status: 'todo', part: where, note: 'a table loader (' + l.file + ') is not converted; the treatment runs it as it is' });
for (const d of stage.dumpers || []) report.push({ status: 'todo', part: where, note: 'a table dumper (' + d.file + ') is not converted; the treatment runs it as it is' });
const header = stage.showHeader ? t.background.active.find((b) => b.type === 'header') : null;
let active = screens.screen(stage.active, s, { header });
if (interactive) {
active = '<link rel="stylesheet" href="/participant/ztree-app.css"><script src="/participant/ztree-plot.js"></script><script src="/participant/ztree-app.js"></script>\n' +
`<div class="zt-screen" data-zt-stage="${s.stageId}">\n${active}</div>\n`;
}
w(' stage.activeScreen = `\n' + active.replace(/`/g, '\\`') + '`;');
if (stage.waiting.length) {
const wait = screens.screen(stage.waiting, { ...s, where: where + ', waiting screen', interactive: false, form: { inputs: [], buttons: [] } });
w(' stage.waitingScreen = `\n' + wait.replace(/`/g, '\\`') + '`;');
}
// What only the server can work out for the screens (in player.zt_, as z.refresh has it).
const table = (name, comment, entries) => {
if (entries.length === 0) return;
w(` // ${comment}`);
w(` stage.${name} = {`);
w(indent(entries.join('\n'), ' '));
w(' };');
};
table('values', 'Values on the screens the page cannot work out (table functions, layouts that are variables).', s.values);
table('shows', 'Boxes\' display conditions the page cannot work out.', s.shows);
table('lists', 'Contract lists: their rows (each with its cells, worked out for the subject).', s.lists);
table('buttons', 'The buttons, by box and button: what pressing one does (z.press, from a ztButton message).', s.buttons);
table('chats', 'Chat boxes: what an entry is (z.chat, from a ztChat message).', s.chats);
table('plots', 'Plot boxes: their shapes, worked out for the subject (draw), and their inputs (z.plotInput, from a ztPlot message).', s.plots);
if (interactive) {
interactiveStages++;
report.push({ status: 'converted', part: where, note: 'interactive: its buttons run their programs as they are pressed' });
}
// A form's inputs: their checks.
const inputs = s.form.inputs;
const buttons = s.form.buttons;
for (const item of inputs) {
const l = parseLayout(item.layout, { rt: null });
const field = { type: 'number' };
const bound = (code) => (/^-?[\d.]+$/.test(code.trim()) ? code.trim() : `(player) => { const period = player.group.period; const g = z.globals(period); return ${tr.subjectExpr(code, where)}; }`);
if (item.min) field.min = bound(item.min);
if (item.max) field.max = bound(item.max);
// A number's layout is its step: what is entered must be a multiple of it.
const stepText = String(item.layout || '').trim();
if (l.kind === 'number' && stepText !== '') field.step = bound(stepText);
if (l.kind === 'checkbox') field.blank = 'true';
(stageFields[s.stageId] = stageFields[s.stageId] || {})['player.' + item.variable] = field;
}
w(` // FIELDS ${s.stageId}`);
if (inputs.length) w(` stage.formFields = ${js([...new Set(inputs.map((x) => 'player.' + x.variable))])};`);
if (!interactive) {
// Checkers: the boxes' and their buttons'.
const checkers = [];
for (const b of stage.active) {
const visit = (box) => {
checkers.push(...box.items.filter((x) => x.kind === 'checker'));
box.boxes.forEach(visit);
};
visit(b);
}
for (const b of buttons) checkers.push(...b.checkers);
if (checkers.length) {
w(' // Checkers: each condition, on what was entered; else its message.');
w(' stage.validate = function (player, values) {');
w(' const period = player.group.period;');
w(' const g = z.globals(period);');
w(' const entered = Object.create(player);');
w(" for (const [name, value] of Object.entries(values)) entered[name.replace(/^player\\./, '')] = value;");
for (const c of checkers) {
const cond = tr.subjectExpr(c.condition, where).replace(/\bplayer\./g, 'entered.');
w(` if (!(${cond})) return ${tr.text(c.message || 'Not allowed.', s.subject, where).replace(/\bplayer\./g, 'entered.')};`);
}
w(' };');
}
// The button's programs: when the subject leaves the stage.
const withPrograms = buttons.filter((b) => b.programs.length);
if (withPrograms.length) {
w(' // The button\'s programs, as the subject leaves the stage.');
w(' stage.playerEnd = function (player) {');
w(' const period = player.group.period;');
w(' const g = z.globals(period);');
w(' const players = [player];');
w(indent(withPrograms[0].programs.map((p) => tr.program(p, where + ', button ' + withPrograms[0].name).join('\n')).join('\n'), ' '));
w(' };');
}
}
w('}');
if (report.filter((x) => x.status === 'todo').length === before) report.push({ status: 'converted', part: where, note: '' });
});
if (interactiveStages) {
w();
w('// Messages from the pages of interactive stages (participant/ztree-app.js): a button pressed.');
w('// They convert their own values (box paths such as "1.0" are not numbers).');
w('app.messages.ztButton = function (d) { z.press(this.player(), d); };');
w('app.messages.ztButton.convertsOwnValues = true;');
w('app.messages.ztChat = function (d) { z.chat(this.player(), d); };');
w('app.messages.ztChat.convertsOwnValues = true;');
w('app.messages.ztPlot = function (d) { z.plotInput(this.player(), d); };');
w('app.messages.ztPlot.convertsOwnValues = true;');
}
// Inputs' checks: those that are the same on every stage together, in app.fields.
const fieldCode = (f) => `{ ${Object.entries(f).map(([a, b]) => `${a}: ${a === 'type' ? js(b) : b}`).join(', ')} }`;
const byName = {};
for (const fields of Object.values(stageFields)) {
for (const [k, f] of Object.entries(fields)) (byName[k] = byName[k] || new Set()).add(fieldCode(f));
}
const shared = Object.keys(byName).filter((k) => byName[k].size === 1);
const fieldLines = shared.map((k) => ` ${js(k)}: ${[...byName[k]][0]},`);
for (let k = 0; k < out.length; k++) {
const m = /^ {4}\/\/ FIELDS (\S+)$/.exec(out[k]);
if (m == null) continue;
const own = Object.entries(stageFields[m[1]] || {}).filter(([name]) => !shared.includes(name));
out.splice(k, 1, ...(own.length ? [' // Its inputs\' checks, which differ from another stage\'s.', ' stage.fields = {', ...own.map(([name, f]) => ` ${js(name)}: ${fieldCode(f)},`), ' };'] : []));
}
const at = out.findIndex((l) => l.startsWith('// The parameter table'));
out.splice(at, 0, '// The inputs: numbers, from their minimum to their maximum.', 'app.fields = {', ...fieldLines, '};', '');
return { code: out.join('\n') + '\n', report };
}
/** CONVERSION.md. */
function reportMarkdown(name, level, report) {
const lines = [`# ${name}: converted from z-Tree`, '', `**${level}.** jtree converted the z-Tree treatment ${name}.ztt to this jtree app:`, '',
'- `app.jtt`: its periods, parameter table and stages, its programs translated from z-Tree\'s language,',
' its boxes as screens (a form\'s inputs in `app.fields`, checkers in `stage.validate`; an interactive',
' stage\'s buttons in `stage.buttons`, its lists, chats and plots worked out on the server);',
'- `ztree.cjs`: what the translated code uses for z-Tree\'s tables and functions.', ''];
const todos = report.filter((i) => i.status === 'todo');
if (todos.length) {
lines.push('## Needs work by hand', '', 'Each is a `TODO` in the code (which throws if it runs, unless it says it is skipped) or in a screen.', '');
for (const i of todos) lines.push(`- **${i.part}**: ${i.note}`);
lines.push('');
}
lines.push('## Converted', '');
for (const i of report.filter((x) => x.status === 'converted')) lines.push(`- ${i.part}${i.note ? ': ' + i.note : ''}`);
lines.push('', '## Differences from z-Tree', '',
'- Boxes follow one another on the screen: z-Tree\'s placement is not kept.',
'- Comparisons are exact (z-Tree allows for rounding errors), and conditions are true or false rather than 1 or 0.',
'- A stage whose buttons all end it is a form, sent by its button; on the others (contract lists, chat, plots, ...),',
' buttons send messages and the stage goes on, as in z-Tree.',
'- z-Leaf\'s calculator is not offered (its box is empty).', '');
return lines.join('\n');
}
/** Converts the treatment in file to a jtree app in outDir; {name, level, report, files}. */
async function convertTreatmentFile(file, outDir) {
const t = readTreatment(fs.readFileSync(file));
const name = path.basename(file, path.extname(file));
const { code, report } = convertTreatment(t, name);
fs.outputFileSync(path.join(outDir, 'app.jtt'), code);
fs.copyFileSync(RUNTIME, path.join(outDir, 'ztree.cjs'));
const level = report.some((i) => i.status === 'todo') ? 'Converts with TODOs' : 'Converts';
fs.outputFileSync(path.join(outDir, 'CONVERSION.md'), reportMarkdown(name, level, report));
return { name, level, report, files: ['app.jtt', 'ztree.cjs', 'CONVERSION.md'] };
}
module.exports = { convertTreatment, convertTreatmentFile };
if (require.main === module) {
const [file, outArg] = process.argv.slice(2);
if (file == null) {
console.log('Usage: node convert.js <treatment.ztt> [<output folder>]');
process.exit(1);
}
const outDir = outArg || file.replace(/\.ztt$/i, '') + '-jtree';
convertTreatmentFile(file, outDir).then((r) => {
console.log(r.name + ': ' + r.level + ', in ' + outDir);
for (const i of r.report.filter((x) => x.status === 'todo')) console.log(' TODO ' + i.part + ': ' + i.note);
}, (err) => {
console.log(String(err.message || err));
process.exit(1);
});
}