Source: dialects/ztree/runtime.js

/*
 * A z-Tree treatment running in a jtree session (one Run per app of a session): its tables, its
 * programs, its buttons and its screens.
 *
 * Tables, each period: globals (one record), subjects (one per participant: Period, Subject,
 * Group, Profit, TotalProfit, Participate), summary (one per period, all periods'), contracts
 * (created by the programs and contract boxes), session (one per participant, all periods').
 * OLD<table> is the period before's.
 */

const fs = require('fs');
const path = require('path');
const lang = require('./lang.js');
const render = require('./render.js');

const BUILT_IN = {
    globals: ['Period', 'NumPeriods', 'RepeatTreatment'],
    subjects: ['Period', 'Subject', 'Group', 'Profit', 'TotalProfit', 'Participate', 'LeaveStage'],
    summary: ['Period'],
    session: ['Subject', 'FinalProfit', 'ShowUpFee', 'MoneyAdded'],
    contracts: [],
};

/** Parsed programs, by their code. */
const compiled = new Map();
function program(code) {
    if (!compiled.has(code)) compiled.set(code, lang.parse(code || ''));
    return compiled.get(code);
}

class Run {
    /**
     * @param {Object} treatment From ztt.js.
     * @param {App} app The session's app.
     */
    constructor(treatment, app) {
        this.treatment = treatment;
        this.app = app;
        this.periods = [];     // by period index: {globals, subjects, contracts, summary, started}
        this.sessionRecords = []; // by subject index
        this.nextId = 1;
        this.timers = [];
        this.selected = new Map(); // participant id -> {boxPath: record id}
        this.errors = [];
        this.vars = variables(treatment);
        this.arrays = arrayDeclarations(treatment);
        // Tables that last the treatment or the session (lifetime TREATMENT or SESSION): their
        // records, kept across periods; the others' are a period's.
        this.lasting = {};
        for (const t of treatment.tables || []) {
            if (/^(TREATMENT|SESSION)$/i.test(t.lifetime || '') && !BUILT_IN[t.name]) this.lasting[t.name] = [];
        }
    }

    // --- Tables -------------------------------------------------------------------------

    /** The runtime programs see in period p (0-based): tables, and what they call. */
    /** Whose rules the treatment runs by: its session's mode (see Session#engineMode), else jtree's. */
    mode() {
        const session = this.app && this.app.session;
        return session != null && typeof session.engineMode === 'function' ? session.engineMode() : 'jtree';
    }

    view(p) {
        const run = this;
        const state = this.periods[p];
        return {
            period: p,
            // Whose rules apply where the engines differ (see lang.js's zTreeRules).
            mode: run.mode(),
            records(table, { old } = {}) {
                const s = old ? run.periods[p - 1] : state;
                if (s == null) return [];
                switch (table) {
                    case 'subjects': return s.subjects;
                    case 'globals': return [s.globals];
                    case 'contracts': return s.contracts;
                    case 'summary': return run.periods.slice(0, (old ? p - 1 : p) + 1).filter(Boolean).map((x) => x.summary);
                    case 'session': return run.sessionRecords;
                    default:
                        if (run.lasting[table] != null) return run.lasting[table];
                        return (s.other[table] = s.other[table] || []);
                }
            },
            globals() { return state.globals; },
            isVar(table, name) { return run.vars[table] != null && run.vars[table].has(name); },
            declaredArray(table, name) { return (run.arrays[table] || {})[name] || null; },
            newRecord(table) { return run.newRecord(p, table); },
            later(seconds, fn) { run.later(seconds, fn); },
            time() { return (Date.now() - state.startedAt) / 1000; },
            history(ctx) {
                const subject = ctx.rec.Subject;
                return run.periods.slice(0, p).filter(Boolean).map((s) => s.subjects.find((r) => r.Subject === subject)).filter(Boolean);
            },
            contractRows(box, ctx) { return run.contractRows(box, ctx, this); },
        };
    }

    newRecord(p, table) {
        const rec = { _id: this.nextId++ };
        for (const name of this.vars[table] || []) rec[name] = 0;
        rec.Period = p + 1;
        const state = this.periods[p];
        if (table === 'contracts') state.contracts.push(rec);
        else if (this.lasting[table] != null) this.lasting[table].push(rec);
        else if (table !== 'subjects' && table !== 'globals') (state.other[table] = state.other[table] || []).push(rec);
        this.initRecord(p, table, rec);
        return rec;
    }

    /**
     * A new record of a table: the background's programs for its table run on it, as z-Tree
     * runs them (noda's contracts get Buyer = -1 and so on, GIMS's records their arrays).
     */
    initRecord(p, table, rec) {
        const programs = this.treatment.background.programs.filter((pr) => (pr.table || 'subjects') === table);
        if (programs.length === 0 || ['subjects', 'globals', 'summary', 'session'].includes(table)) return;
        this.initDepth = (this.initDepth || 0) + 1;
        try {
            if (this.initDepth > 20) throw new Error('records of ' + table + ' make records without end');
            const ctx = { rt: this.view(p), table, rec, outer: null };
            for (const pr of programs) {
                if (pr.condition && !lang.bool(this.value(pr.condition, ctx))) continue;
                this.exec(pr.code, ctx);
            }
        } catch (err) {
            this.note(err.message);
        } finally {
            this.initDepth--;
        }
    }

    /** Starts period p (0-based) for the subjects (the session's participants, in order). */
    startPeriod(p, numSubjects) {
        const t = this.treatment;
        const before = this.periods[p - 1];
        const state = {
            globals: { _id: this.nextId++, Period: p + 1, NumPeriods: t.periods.length, RepeatTreatment: 0 },
            subjects: [],
            contracts: [],
            summary: { _id: this.nextId++, Period: p + 1 },
            other: {},
            startedAt: Date.now(),
            globalsRun: new Set(), // stages whose globals programs have run (stages started one by one)
        };
        this.periods[p] = state;
        for (let i = 0; i < numSubjects; i++) {
            const cell = paramCell(t, p, i) || { group: 1, program: '' };
            const rec = { _id: this.nextId++ };
            for (const name of this.vars.subjects) rec[name] = 0;
            Object.assign(rec, {
                Period: p + 1, Subject: i + 1, Group: cell.group == null ? 1 : cell.group, Profit: 0,
                TotalProfit: before != null && before.subjects[i] != null ? before.subjects[i].TotalProfit : 0, Participate: 1,
            });
            state.subjects.push(rec);
            if (this.sessionRecords[i] == null) this.sessionRecords[i] = { _id: this.nextId++, Subject: i + 1 };
        }
        for (const name of this.vars.globals) if (!(name in state.globals)) state.globals[name] = 0;
        const view = this.view(p);
        // The background's table loaders, then its programs, then the parameter table's: the
        // period's, the subject's (role's), then the cell's.
        this.loadTables(p, t.background.loaders);
        this.runPrograms(t.background.programs, view, state.subjects);
        const periodProgram = (t.periods[p] || {}).program;
        if (periodProgram) this.exec(periodProgram, { rt: view, table: 'globals', rec: state.globals, outer: null });
        state.subjects.forEach((rec, i) => {
            const role = t.subjects[i % Math.max(1, t.subjects.length)];
            if (role && role.program) this.exec(role.program, { rt: view, table: 'subjects', rec, outer: null });
            const cell = paramCell(t, p, i);
            if (cell && cell.program) this.exec(cell.program, { rt: view, table: 'subjects', rec, outer: null });
        });
    }

    /** Ends period p: profits added up. */
    endPeriod(p) {
        const state = this.periods[p];
        if (state == null || state.ended) return;
        state.ended = true;
        for (const rec of state.subjects) rec.TotalProfit = lang.num(rec.TotalProfit) + lang.num(rec.Profit);
        this.clearTimers();
    }

    /**
     * Runs programs (a stage's, the background's) for the subjects starting it: each program, in
     * order, on its table's records (subjects: those starting; globals: once).
     */
    runPrograms(programs, view, subjects, { globalsOnce } = {}) {
        for (const pr of programs) {
            const table = pr.table || 'subjects';
            let records;
            if (table === 'subjects') records = subjects;
            else if (table === 'globals') {
                if (globalsOnce != null) {
                    if (globalsOnce.has(pr)) continue;
                    globalsOnce.add(pr);
                }
                records = [view.globals()];
            } else if (table === 'summary') records = [view.records('summary').slice(-1)[0]];
            else if (table === 'session') records = subjects.map((s) => this.sessionRecords[s.Subject - 1]).filter(Boolean);
            else records = view.records(table);
            for (const rec of records.slice()) {
                const ctx = { rt: view, table, rec, outer: null };
                if (pr.condition && !lang.bool(this.value(pr.condition, ctx))) continue;
                this.exec(pr.code, ctx);
            }
        }
    }

    /** Runs code in ctx; an error is noted (and logged), not thrown. */
    exec(code, ctx) {
        try {
            lang.run(program(code), ctx);
        } catch (err) {
            // The program by its first line (often a comment saying what it is).
            const first = String(code || '').split('\n').map((l) => l.trim()).find((l) => l !== '') || '';
            this.note('in a program on ' + ctx.table + ' (' + first.slice(0, 60) + '): ' + err.message);
        }
    }

    value(code, ctx) {
        try {
            return lang.evaluate(render.expression(code), ctx);
        } catch (err) {
            this.note(code + ': ' + err.message);
            return 0;
        }
    }

    /**
     * The file a table loader or dumper names: its name (z-Tree's may be a Windows path) in
     * folder.
     */
    tableFile(name, folder, p) {
        let text = String(name).trim().replace(/^"|"$/g, '');
        // A z-Tree text with values (GIMS: <>GIMS_P<\Period|...>_<gettime()|1>.zBackup).
        if (text.startsWith('<>') && p != null) {
            text = render.formatText(text, { rt: this.view(p), table: 'globals', rec: this.periods[p].globals, outer: null })
                .replace(/<[^>]*>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&amp;/g, '&');
        }
        return path.join(folder, text.split(/[\\/]/).pop().replace(/[<>:"|?*]/g, '_'));
    }

    /** Whether a loader's or dumper's condition holds (on the globals; none: it does). */
    tableCondition(code, p) {
        if (code == null || String(code).trim() === '') return true;
        return lang.bool(this.value(code, { rt: this.view(p), table: 'globals', rec: this.periods[p].globals, outer: null }));
    }

    /**
     * Table loaders, as z-Tree 5 runs them (before the stage's programs): each whose condition
     * holds reads its file, a dumper's (a line naming a table and its variables, then a line
     * for each record: the table's name and the values; tab-separated), from the session's
     * output folder (where dumpers write, so that a treatment can load what one before dumped),
     * else the treatment's. A table's records are replaced by the file's: a variable the file
     * does not have is 0, as in z-Tree, but for jtree's own in subjects and globals (Period,
     * Subject, Group, Participate; NumPeriods), which its sessions need. A variable the
     * treatment does not have is an error, noted (as z-Tree shows it), and left out.
     */
    loadTables(p, loaders) {
        const session = this.app && this.app.session;
        for (const loader of loaders || []) {
            if (!this.tableCondition(loader.condition, p)) continue;
            const folders = [session && session.getOutputDir(), this.app && this.app.appPath && path.dirname(this.app.appPath)].filter(Boolean);
            const file = folders.map((f) => this.tableFile(loader.file, f, p)).find((f) => fs.existsSync(f));
            if (file == null) {
                this.note('table loader: no file ' + loader.file);
                continue;
            }
            const blocks = [];
            for (const line of fs.readFileSync(file, 'latin1').split(/\r?\n/)) {
                if (line.trim() === '') continue;
                const cells = line.split('\t');
                const last = blocks[blocks.length - 1];
                if (last == null || last.table !== cells[0]) blocks.push({ table: cells[0], names: cells.slice(1), rows: [] });
                else last.rows.push(cells.slice(1));
            }
            for (const b of blocks) this.loadTable(p, b);
        }
    }

    loadTable(p, { table, names, rows }) {
        const state = this.periods[p];
        const known = this.vars[table] || new Set();
        const unknown = names.filter((n) => !known.has(n) && !['Period', 'Subject'].includes(n));
        if (unknown.length) this.note('Error(s) reading table "' + table + '":Variable(s) not defined: ' + unknown.join(', '));
        const value = (text) => {
            const t = String(text).trim();
            if (/^".*"$/.test(t)) return t.slice(1, -1);
            const n = Number(t);
            return t === '' || Number.isNaN(n) ? t : n;
        };
        const fill = (rec, row, keep) => {
            for (const name of known) if (!keep.includes(name)) rec[name] = 0;
            names.forEach((n, i) => { if (!unknown.includes(n) && !keep.includes(n) && i < row.length) rec[n] = value(row[i]); });
            return rec;
        };
        if (table === 'subjects') {
            rows.forEach((row, i) => { if (state.subjects[i] != null) fill(state.subjects[i], row, ['Period', 'Subject', 'Group', 'Participate']); });
        } else if (table === 'globals') {
            if (rows[0] != null) fill(state.globals, rows[0], ['Period', 'NumPeriods', 'RepeatTreatment']);
        } else {
            const records = rows.map((row) => fill({ _id: this.nextId++, Period: p + 1 }, row, ['Period']));
            if (table === 'contracts') state.contracts = records;
            else if (this.lasting[table] != null) this.lasting[table] = records;
            else state.other[table] = records;
        }
        this.onChange();
    }

    /**
     * Table dumpers, as z-Tree 5 runs them (as the stage starts, after its programs): each whose
     * condition holds writes its tables (semicolon-separated names) to its file, in the session's
     * output folder, as z-Tree writes them (see loadTables).
     */
    dumpTables(p, dumpers) {
        const session = this.app && this.app.session;
        if (session == null) return;
        const view = this.view(p);
        const cell = (v) => (typeof v === 'string' ? '"' + v.replace(/[\t\r\n]/g, ' ') + '"' : v == null ? '' :
            typeof v === 'object' ? '' : Number.isNaN(v) ? '-nan(ind)' : v === Infinity ? 'inf' : v === -Infinity ? '-inf' : String(v));
        for (const dumper of dumpers || []) {
            if (!this.tableCondition(dumper.condition, p)) continue;
            const lines = [];
            for (const table of String(dumper.tables || '').split(';').map((t) => t.trim()).filter(Boolean)) {
                const records = view.records(table);
                const names = [];
                for (const rec of records) for (const k of Object.keys(rec)) if (k !== '_id' && !names.includes(k)) names.push(k);
                lines.push([table, ...names].join('\t'));
                for (const rec of records) lines.push([table, ...names.map((n) => cell(rec[n]))].join('\t'));
            }
            const file = this.tableFile(dumper.file, session.getOutputDir(), p);
            fs.mkdirSync(path.dirname(file), { recursive: true });
            fs.writeFileSync(file, lines.map((l) => l + '\r\n').join(''), 'latin1');
        }
    }

    note(message) {
        this.errors.push(message);
        if (this.app.jt != null) this.app.jt.log('z-Tree: ' + message);
    }

    later(seconds, fn) {
        const timer = setTimeout(() => {
            this.timers = this.timers.filter((t) => t !== timer);
            try {
                fn();
            } catch (err) {
                this.note('in a later block: ' + err.message);
            }
            this.onChange();
        }, seconds * 1000);
        if (timer.unref) timer.unref();
        this.timers.push(timer);
    }

    clearTimers() {
        this.timers.forEach(clearTimeout);
        this.timers = [];
    }

    /** Called when the tables change outside a participant's action (later blocks). */
    onChange() {}

    /** A contract list's rows: the box table's records that meet its condition, sorted. */
    contractRows(box, ctx, view) {
        const table = box.table || 'contracts';
        let records = view.records(table).filter((rec) => !box.tableCondition ||
            lang.bool(this.value(box.tableCondition, { rt: view, table, rec, outer: ctx })));
        if (box.sorting) {
            // Keys separated by ;, each descending with a - before it: "-p; -contractID".
            const keys = box.sorting.split(';').map((k) => k.trim()).filter(Boolean).map((k) => ({
                desc: k.startsWith('-'), code: k.replace(/^[-+]\s*/, ''),
            }));
            const k = (rec, key) => lang.num(this.value(key.code, { rt: view, table, rec, outer: ctx }));
            records = records.slice().sort((a, b) => {
                for (const key of keys) {
                    const d = k(a, key) - k(b, key);
                    if (Math.abs(d) > 1e-12) return key.desc ? -d : d;
                }
                return 0;
            });
        }
        return records;
    }

    // --- Buttons ------------------------------------------------------------------------

    /**
     * A subject pressed a button of a box of their stage's screen: its inputs are checked and put
     * in their record (or a new one, or the chosen one, for contract boxes), its checkers are
     * checked, and its programs run. Returns {error} (a message for the subject), or {leave}
     * (whether the subject leaves the stage).
     * @param {Object} d {box (path), button (index), values (by input name), record (chosen)}
     */
    press(p, subjectIndex, stage, d) {
        const view = this.view(p);
        const state = this.periods[p];
        const subject = state.subjects[subjectIndex];
        const box = findBox(stage, d.box);
        if (box == null) return { error: 'There is no such box.' };
        // An item with buttons (layout !button) leaves the stage, as a button with no checkers.
        const button = d.itemButton ? { name: '', checkers: [], programs: [] } : box.buttons[Number(d.button)];
        if (button == null) return { error: 'There is no such button.' };
        const own = { rt: view, table: 'subjects', rec: subject, outer: null };
        let target = subject;
        let targetTable = 'subjects';
        let isNew = false;
        if (box.type === 'contractCreation') {
            targetTable = box.table || 'contracts';
            target = { _id: null };
            for (const name of this.vars[targetTable] || []) target[name] = 0;
            target.Period = p + 1;
            this.initRecord(p, targetTable, target);
            isNew = true;
        } else if (box.type === 'contractList') {
            targetTable = box.table || 'contracts';
            const rows = this.contractRows(box, own, view);
            target = rows.find((r) => String(r._id) === String(d.record));
            if (target == null) {
                return { error: rows.length === 0 ? 'There is nothing to choose.' : 'Please choose a row first.' };
            }
        }
        const ctx = targetTable === 'subjects' ? own : { rt: view, table: targetTable, rec: target, outer: own };

        // Inputs: the box's, and (for a standard box's button) those of the screen's boxes that
        // have no buttons of their own; checked, then set (on a copy until the checkers pass).
        const inputs = box.items.map((item) => [item, d.values || {}]);
        if (!['contractCreation', 'contractList', 'chat'].includes(box.type)) {
            const visit = (b, path) => {
                // A box not shown (its display condition false) asks for nothing, nor do its boxes.
                if (b.condition && !lang.bool(this.value(b.condition, own))) return;
                if (path !== String(d.box) && ['standard', 'grid'].includes(b.type) && b.buttons.length === 0) {
                    for (const item of b.items) inputs.push([item, (d.others || {})[path] || {}]);
                }
                b.boxes.forEach((c, i) => visit(c, path + '.' + i));
            };
            stage.active.forEach((b, i) => visit(b, String(i)));
        }
        const updates = {};
        for (const [item, values] of inputs) {
            if (item.kind !== 'item' || !item.input || !item.variable) continue;
            const raw = values[item.variable];
            const text = raw == null ? '' : String(raw).trim();
            const layout = render.parseLayout(item.layout, ctx);
            if (text === '') {
                if (layout.kind === 'checkbox') { updates[item.variable] = 0; continue; }
                return { error: 'Please enter a value for "' + plain(item.label || item.variable) + '".' };
            }
            const v = Number(text.replace(',', '.'));
            if (!Number.isFinite(v)) return { error: '"' + text + '" is not a number.' };
            const min = item.min ? lang.num(this.value(item.min, ctx)) : null;
            const max = item.max ? lang.num(this.value(item.max, ctx)) : null;
            if ((min != null && v < min - 1e-9) || (max != null && v > max + 1e-9)) {
                return { error: 'Please enter a number from ' + render.formatValue(min, layout, ctx) + ' to ' + render.formatValue(max, layout, ctx) + '.' };
            }
            if (layout.kind === 'number' && layout.step > 0 && Math.abs(v / layout.step - Math.round(v / layout.step)) > 1e-6) {
                return { error: 'Please enter a multiple of ' + layout.step + '.' };
            }
            updates[item.variable] = v;
        }
        // An input into a variable, or into an array's element (CQ[1], as GIMS has them).
        const isName = (k) => /^\w+$/.test(k);
        const set = (k, v) => {
            if (isName(k)) target[k] = v;
            else lang.run({ body: [{ k: 'set', target: lang.parse(k + ' = 0;').body[0].target, value: { k: 'num', v } }] }, ctx);
        };
        const saved = {};
        for (const [k, v] of Object.entries(updates)) {
            saved[k] = isName(k) ? (Object.prototype.hasOwnProperty.call(target, k) ? target[k] : undefined) : lang.evaluate(lang.parseExpr(k), ctx);
            set(k, v);
        }
        // Checkers: the box's, then the button's.
        for (const checker of [...box.items.filter((i) => i.kind === 'checker'), ...button.checkers]) {
            if (!lang.bool(this.value(checker.condition, ctx))) {
                for (const [k, v] of Object.entries(saved)) {
                    if (v === undefined) delete target[k]; else set(k, v);
                }
                return { error: render.formatText(checker.message, ctx).replace(/<br>/g, '\n') || 'Not allowed.' };
            }
        }
        if (isNew) {
            target._id = this.nextId++;
            if (targetTable === 'contracts') state.contracts.push(target);
            else (state.other[targetTable] = state.other[targetTable] || []).push(target);
        }
        // The button's table loaders (GIMS's "Load from disk"), before its programs.
        this.loadTables(p, button.loaders);
        // The button's programs: on the box's record for its table, the subject's, the globals'.
        for (const pr of button.programs) {
            const table = pr.table || 'subjects';
            let records;
            if (table === targetTable && targetTable !== 'subjects') records = [target];
            else if (table === 'subjects') records = [subject];
            else if (table === 'globals') records = [state.globals];
            else records = view.records(table);
            for (const rec of records) {
                const c = { rt: view, table, rec, outer: table === 'subjects' && rec === subject ? null : own };
                if (pr.condition && !lang.bool(this.value(pr.condition, c))) continue;
                this.exec(pr.code, c);
            }
        }
        // Whether it leaves the stage: as the button says, else as z-Tree has it ("Normal"): it
        // does, unless the stage is an auction (left after its timeout always) and the button is
        // in a contract creation or list box.
        // Its table dumpers (GIMS's settings, saved with "START NOW"), after its programs.
        this.dumpTables(p, button.dumpers);
        const leaves = d.itemButton || leavesStage(button, box, stage);
        return { leave: leaves };
    }

    /**
     * A subject used a plot input of a plot box: {box, input (its id: the item's path in the box,
     * then @ and the records it was drawn for, table:id, innermost first), x, y (where), x0, y0,
     * x1, y1 (a drag's start and end)}. A click (action 0) makes a record of the input's table at
     * the point (or, for subjects, moves the subject's); a click on an item (1) or a drag (2) is
     * its record's. Then the input's programs run, each on its table's record.
     */
    plotInput(p, subjectIndex, stage, d) {
        const view = this.view(p);
        const state = this.periods[p];
        const subject = state.subjects[subjectIndex];
        const box = findBox(stage, d.box);
        if (box == null || box.type !== 'plot') return { error: 'There is no such plot.' };
        const [path, recordsText] = String(d.input || '').split('@');
        const input = plotItemAt(box, path);
        if (input == null || input.kind !== 'input') return { error: 'There is no such input.' };
        // The records it was drawn for, as contexts, outermost (the subject) first.
        const own = { rt: view, table: 'subjects', rec: subject, outer: null };
        const chain = [];
        let c = own;
        for (const ref of (recordsText ? recordsText.split(',') : []).reverse()) {
            const [table, id] = [ref.slice(0, ref.lastIndexOf(':')), ref.slice(ref.lastIndexOf(':') + 1)];
            const rec = view.records(table).find((r) => String(r._id) === id);
            if (rec == null) return { error: 'That is gone.' };
            c = { rt: view, table, rec, outer: c };
            chain.unshift(c);
        }
        const inner = chain[0] || own;
        if (input.condition && !lang.bool(this.value(input.condition, inner))) return {};
        const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
        const coords = {};
        if (input.xVar) coords[input.xVar] = input.action === 2 ? n(d.x1) - n(d.x0) : n(d.x);
        if (input.yVar) coords[input.yVar] = input.action === 2 ? n(d.y1) - n(d.y0) : n(d.y);
        if (input.action === 2) {
            for (const [k, v] of [['x0Var', d.x0], ['y0Var', d.y0], ['x1Var', d.x1], ['y1Var', d.y1]]) if (input[k]) coords[input[k]] = n(v);
        }
        const table = input.table || 'subjects';
        let target;
        if (input.action === 0) {
            target = table === 'subjects' ? own : { rt: view, table, rec: this.newRecord(p, table), outer: inner };
        } else {
            target = chain.find((x) => x.table === table) || inner;
        }
        Object.assign(target.rec, coords);
        for (const pr of input.programs) {
            const t = pr.table || 'subjects';
            let pc = t === target.table ? target : chain.find((x) => x.table === t);
            if (pc == null) {
                if (t === 'subjects') pc = own;
                else if (t === 'globals') pc = { rt: view, table: 'globals', rec: state.globals, outer: inner };
                else continue;
            }
            // A drag's start and end are the program's record's too (its programs read them there).
            if (input.action === 2 && pc !== target) Object.assign(pc.rec, coords);
            if (pr.condition && !lang.bool(this.value(pr.condition, pc))) continue;
            this.exec(pr.code, pc);
        }
        return {};
    }

    /** A subject's chat entry: a new record of the chat box's table, with its programs run. */
    chat(p, subjectIndex, stage, d) {
        const view = this.view(p);
        const subject = this.periods[p].subjects[subjectIndex];
        const box = findBox(stage, d.box);
        if (box == null || box.type !== 'chat' || !box.variable) return;
        const text = String(d.text || '').slice(0, 1000);
        if (text.trim() === '') return;
        const rec = this.newRecord(p, box.table || 'contracts');
        rec[box.variable] = text;
        const own = { rt: view, table: 'subjects', rec: subject, outer: null };
        for (const pr of box.programs) this.exec(pr.code, { rt: view, table: box.table || 'contracts', rec, outer: own });
    }

    // --- Screens ------------------------------------------------------------------------

    /** The screen a subject sees: stage's active or waiting screen (the background's, if it has none). */
    screen(p, subjectIndex, stage, which, participantId, deadline) {
        const t = this.treatment;
        const view = this.view(p);
        const state = this.periods[p];
        if (state == null) return '';
        const ctx = { rt: view, table: 'subjects', rec: state.subjects[subjectIndex], outer: null, errors: [] };
        const boxes = which === 'active' ? stage.active : (stage.waiting.length ? stage.waiting : t.background.waiting);
        const header = stage.showHeader ? t.background.active.find((b) => b.type === 'header') : null;
        const html = render.renderScreen(boxes, {
            ctx, screen: which, header, period: p + 1, numPeriods: t.periods.length,
            selected: this.selected.get(participantId) || {}, rt: this,
        });
        if (ctx.errors.length) ctx.errors.forEach((e) => this.note('on a screen: ' + e));
        return '<div class="ztree-screen" data-which="' + which + '"' + (deadline ? ' data-deadline="' + deadline + '"' : '') + '>' + html + '</div>';
    }
}

/** Whether a button leaves the stage (see Run#press). */
function leavesStage(button, box, stage) {
    if (typeof button.leaveStage === 'boolean') return button.leaveStage;
    return !(stage.timeoutAlways === true && ['contractCreation', 'contractList'].includes(box.type));
}

/** The plot item at path in a plot box ("1.0" in a graph; "2:0" the first input of item 2). */
function plotItemAt(box, path) {
    let list = box.plotItems || [];
    let item = null;
    for (const part of String(path).split('.')) {
        const [i, k] = part.split(':').map(Number);
        item = list[i];
        if (item == null) return null;
        if (k != null && !Number.isNaN(k)) item = (item.inputs || [])[k];
        if (item == null) return null;
        list = item.items || [];
    }
    return item;
}

/**
 * The parameter table's cell of subject i in period p; for subjects beyond the treatment's (a
 * session with more participants), the treatment's subjects' cells in turn.
 */
function paramCell(t, p, i) {
    const n = Math.max(1, t.subjects.length);
    return t.params[p * n + (i % n)] || null;
}

/** The box at path ("2", "1.0" in a container) of a stage's active screen. */
function findBox(stage, path) {
    const parts = String(path).split('.').map(Number);
    let list = stage.active;
    let box = null;
    for (const i of parts) {
        box = list[i];
        if (box == null) return null;
        list = box.boxes;
    }
    return box;
}

const plain = (s) => String(s).replace(/^<>/, '').replace(/<[^>]*>/g, '');

/** The arrays the treatment's programs declare, by table (see lang.js's arrayDeclarations). */
function arrayDeclarations(t) {
    const out = {};
    const add = (code, table) => {
        try {
            lang.arrayDeclarations(program(code), table, out);
        } catch (err) {
            // A program that does not parse declares nothing.
        }
    };
    const visitBox = (box) => {
        for (const b of box.buttons) b.programs.forEach((pr) => add(pr.code, pr.table || 'subjects'));
        box.programs.forEach((pr) => add(pr.code, pr.table || 'contracts'));
        box.boxes.forEach(visitBox);
    };
    for (const stage of [t.background, ...t.stages]) {
        stage.programs.forEach((pr) => add(pr.code, pr.table || 'subjects'));
        [...stage.active, ...stage.waiting].forEach(visitBox);
    }
    return out;
}

/** The variables of each table: built in, set by the treatment's programs, or by inputs. */
function variables(t) {
    const out = {};
    for (const [table, names] of Object.entries(BUILT_IN)) out[table] = new Set(names);
    const add = (code, table) => {
        let assigned;
        try {
            assigned = lang.assignedVariables(program(code), table);
        } catch (err) {
            return;
        }
        for (const [tb, names] of Object.entries(assigned)) {
            out[tb] = out[tb] || new Set();
            names.forEach((n) => out[tb].add(n));
        }
    };
    const visitBox = (box) => {
        const table = ['contractCreation', 'contractList', 'chat'].includes(box.type) ? (box.table || 'contracts') : 'subjects';
        for (const item of box.items) {
            if (item.kind === 'item' && item.input && /^\w+$/.test(item.variable || '')) {
                out[table] = out[table] || new Set();
                out[table].add(item.variable);
            }
        }
        for (const b of box.buttons) b.programs.forEach((pr) => add(pr.code, pr.table || 'subjects'));
        box.programs.forEach((pr) => add(pr.code, pr.table || 'contracts'));
        const plotItems = (items) => {
            for (const item of items || []) {
                for (const inp of [...(item.kind === 'input' ? [item] : []), ...(item.inputs || [])]) {
                    const t = inp.table || 'subjects';
                    out[t] = out[t] || new Set();
                    for (const k of ['xVar', 'yVar', 'x0Var', 'y0Var', 'x1Var', 'y1Var']) if (/^\w+$/.test(inp[k] || '')) out[t].add(inp[k]);
                    (inp.programs || []).forEach((pr) => add(pr.code, pr.table || 'subjects'));
                }
                plotItems(item.items);
            }
        };
        plotItems(box.plotItems);
        if (box.type === 'chat' && box.variable) {
            out[box.table || 'contracts'] = out[box.table || 'contracts'] || new Set();
            out[box.table || 'contracts'].add(box.variable);
        }
        box.boxes.forEach(visitBox);
    };
    for (const stage of [t.background, ...t.stages]) {
        stage.programs.forEach((pr) => add(pr.code, pr.table || 'subjects'));
        [...stage.active, ...stage.waiting].forEach(visitBox);
    }
    t.periods.forEach((pp) => add(pp.program || '', 'globals'));
    t.params.forEach((cell) => add(cell.program || '', 'subjects'));
    t.subjects.forEach((role) => add(role.program || '', 'subjects'));
    return out;
}

module.exports = { Run, findBox, variables, arrayDeclarations, program, leavesStage };