const runtime = require('./runtime.js');
/*
* oTree apps' bots (their tests.py, a PlayerBot whose play_round yields each page it fills in),
* run by jtree's server for a session's participants: no pages or browsers involved.
*/
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Plays session with its apps' bots until every participant has finished it. Rejects on the
* first problem: a bot expecting another page than its player's, a form refused (or accepted,
* for SubmissionMustFail), an app without bots, or an error in a bot (e.g. a failed expect).
* @param {Session} session A started session.
* @param {Object} [opts]
* @param {number} [opts.timeout=60000] ms to finish in.
* @param {number} [opts.caseIndex=0] Which of a PlayerBot's cases to play.
* @param {function(App): ?string} [opts.botsFor] The Python package whose bots play an app: by
* default the app's own, for an oTree app. (A jtree app converted from an oTree app is played by
* the oTree app's bots.)
* @param {function(Player): string} [opts.htmlFor] The page a player is on, as the bots see it
* (self.html): by default what the oTree app rendered.
* @param {false|'missing'|'always'|string[]} [opts.generic=false] Whether a generic bot plays (it
* fills in each page's form with valid values): for apps without bots, for all, or for apps without
* bots and the apps named (those whose own bots are broken).
*/
async function runBots(session, { timeout = 60000, caseIndex = 0, botsFor = (app) => (app.otree == null ? null : app.otree.pkg),
htmlFor = (player) => player.otreeHtml || '', generic = false } = {}) {
const bridge = runtime.getBridge();
const bots = new Map(); // participant id -> {round, handle, done}
// Participants whose bots have played every page, up to the session's last, which they stay on.
const finished = new Set();
const end = Date.now() + timeout;
for (;;) {
const participants = Object.values(session.participants);
if (participants.every((p) => p.isFinishedSession() || finished.has(p.id))) {
return;
}
if (Date.now() > end) {
throw new Error('bots did not finish in ' + timeout + ' ms');
}
for (const participant of participants) {
if (finished.has(participant.id)) continue;
const player = participant.player;
if (player == null || player.status !== 'playing') continue;
const app = player.app();
const stage = player.stage;
const pkg = botsFor(app);
if (pkg == null) {
throw new Error(participant.id + ' is in ' + app.shortId + ', which has no bots (not an oTree app)');
}
if (stage.playerStart == null || stage.formFields == null) continue; // a wait page, ending by itself
let bot = bots.get(participant.id);
const round = player.roomId();
// The generic bot: for apps without bots (generic: 'missing'), for all ('always'), or for
// those and the apps named.
if (generic === 'always' || (generic && !bridge.has_bots(pkg))
|| (Array.isArray(generic) && generic.includes(app.shortId))) {
const at = round + '/' + stage.id;
if (bot != null && bot.done === at) continue;
if (player.otreeError) {
throw new Error(participant.id + ': page ' + stage.id + ' could not be shown: ' + player.otreeError);
}
submitGeneric(session, app, player, stage);
bots.set(participant.id, { round, handle: null, done: at });
continue;
}
if (bot == null || bot.round !== round) {
if (!bridge.has_bots(pkg)) {
throw new Error(app.shortId + ' has no bots (a tests.py with a PlayerBot)');
}
bot = { round, handle: bridge.bot_start(pkg, player, caseIndex), done: null };
bots.set(participant.id, bot);
}
const at = round + '/' + stage.id;
if (bot.done === at) continue; // submitted; the server has yet to move them on
if (player.otreeError) {
throw new Error(participant.id + ': page ' + stage.id + ' could not be shown: ' + player.otreeError);
}
const html = String(htmlFor(player));
const next = bridge.bot_next(bot.handle, html);
const step = next == null ? null : JSON.parse(next);
const where = participant.id + ', round ' + player.group.period.id + ' of ' + app.shortId;
if (step == null) {
if (isSessionsLastPage(session, player)) {
finished.add(participant.id);
continue;
}
throw new Error(where + ': the bot has finished its round, but the player is on ' + stage.id);
}
if (step.page !== stage.id) {
throw new Error(where + ': the bot expects page ' + step.page + ', but the player is on ' + stage.id);
}
const values = {};
for (const [name, value] of Object.entries(step.data)) {
const full = stage.formFields.find((f) => f.endsWith('.' + name)) || 'player.' + name;
// As oTree's bots do (unless check_html=False): what they fill in is on the page.
if (step.check_html && !step.timeout_happened && !html.includes('name="' + full + '"') && !html.includes('name="' + name + '"')) {
throw new Error(where + ': the bot fills in ' + name + ', which page ' + stage.id + ' does not have');
}
// As a browser sends oTree's pages' values (True and False as Python writes them).
values[full] = value === true ? 'True' : value === false ? 'False' : String(value);
}
if (step.timeout_happened) {
player.timedOut = true;
}
const errors = app.submitStage(player, values);
session.emitParticipantUpdates();
if (step.must_fail) {
if (errors == null) {
throw new Error(where + ': page ' + stage.id + ' accepted what the bot said it must refuse');
}
continue;
}
if (errors != null) {
throw new Error(where + ': page ' + stage.id + ' refused the bot\'s ' + JSON.stringify(step.data) + ': ' + JSON.stringify(errors));
}
bot.done = at;
}
await sleep(5);
}
}
/**
* The generic bot's submission of the page player is on: a valid value for each of its form's
* fields (their declarations, app.fields: a choice, a number from min to max, yes or no, a word);
* if the page refuses (its error_message, say), other values, up to 30 times.
*/
function submitGeneric(session, app, player, stage) {
const declared = { ...(app.fields || {}), ...(stage.fields || {}) };
const of = (v) => (typeof v === 'function' ? v(player) : v);
const pick = (list) => list[Math.floor(Math.random() * list.length)];
const valueFor = (field) => {
if (field == null) return TEXTS[textIndex % TEXTS.length];
const choices = of(field.choices);
if (Array.isArray(choices) && choices.length) {
const c = pick(choices);
return Array.isArray(c) ? c[0] : c;
}
const lo = of(field.min);
const hi = of(field.max);
switch (field.type) {
case 'bool': return pick([true, false]);
case 'int': {
if ((lo == null || hi == null) && attempt > 0) return unbounded(lo, hi);
const a = lo != null ? Math.ceil(lo) : 0;
const b = hi != null ? Math.floor(hi) : a + 10;
return a + Math.floor(Math.random() * (b - a + 1));
}
case 'number': {
if ((lo == null || hi == null) && attempt > 0) return unbounded(lo, hi);
const a = lo != null ? lo : 0;
const b = hi != null ? hi : a + 10;
return Math.round((a + Math.random() * (b - a)) * 100) / 100;
}
default: return TEXTS[textIndex % TEXTS.length];
}
};
// Texts to try in turn, if a page refuses one (a Prolific ID's 24 letters and digits, ...).
const TEXTS = ['text', '5f2b9c3d4e5f60718293a4b5', 'bot@example.com', '42', 'yes'];
// Numbers to try in turn, for a field with no min or max that a page refuses (an age, a year, ...).
const NUMBERS = [25, 1, 50, 100, 1990, 5, 1000, 2000, 10000, 0];
const unbounded = (lo, hi) => {
const n = NUMBERS[(attempt - 1) % NUMBERS.length];
return Math.min(hi != null ? hi : n, Math.max(lo != null ? lo : n, n));
};
let textIndex = 0;
let attempt = 0;
let errors = null;
for (attempt = 0; attempt < 30; attempt++) {
textIndex = attempt;
const values = {};
for (const name of stage.formFields) {
const v = valueFor(declared[name]);
values[name] = v === true ? 'True' : v === false ? 'False' : String(v);
}
errors = app.submitStage(player, values);
session.emitParticipantUpdates();
if (errors == null) return;
}
throw new Error(player.participant.id + ', round ' + player.group.period.id + ' of ' + app.shortId + ': page ' + stage.id +
' refused the generic bot\'s values: ' + JSON.stringify(errors));
}
/** Whether player is on the last page of the session: its last app's last round and stage. */
function isSessionsLastPage(session, player) {
const app = player.app();
return app === session.apps[session.apps.length - 1] &&
player.group.period.id === app.numPeriods &&
player.stage === app.stages[app.stages.length - 1];
}
/** How many cases the bots of package pkg have (their PlayerBot's cases; 1 if none). */
function botCases(pkg) {
return runtime.getBridge().bot_case_count(pkg);
}
module.exports = { runBots, botCases };