Compare commits

...

3 Commits

Author SHA1 Message Date
d29e0e392b fixed die 2026-02-27 22:49:53 +01:00
9b226d60da working prototype 2026-02-20 00:49:20 +01:00
39ddf07973 updated survey 2026-02-19 22:30:33 +01:00
9 changed files with 1157 additions and 940 deletions

490
index.js
View File

@@ -1,28 +1,24 @@
import { initJsPsych } from 'jspsych'; import { initJsPsych } from 'jspsych';
import 'jspsych/css/jspsych.css'; import 'jspsych/css/jspsych.css';
import jsPsychHtmlKeyboardResponse from '@jspsych/plugin-html-keyboard-response'; import jsPsychHtmlKeyboardResponse from '@jspsych/plugin-html-keyboard-response';
import generateUniqueUsernames from './scripts/name-gen.js';
import jsPsychFullscreen from '@jspsych/plugin-fullscreen'; import jsPsychFullscreen from '@jspsych/plugin-fullscreen';
import jsPsychHtmlButtonResponse from '@jspsych/plugin-html-button-response'; import jsPsychHtmlButtonResponse from '@jspsych/plugin-html-button-response';
import jsPsychLobby from './scripts/plugin-lobby.js';
import jsPsychSurvey from '@jspsych/plugin-survey'; import jsPsychSurvey from '@jspsych/plugin-survey';
import '@jspsych/plugin-survey/css/survey.css'; import '@jspsych/plugin-survey/css/survey.css';
import './styles.css'; import './styles.css';
import { getStimulusMap } from './scripts/text-stimuli.js'; import { getStimulusMap } from './scripts/text-stimuli.js';
import jsPsychObjectMoving from './scripts/plugin-object-moving.js';
import { textStimuli } from './scripts/text-stimuli.js'; import { textStimuli } from './scripts/text-stimuli.js';
import JsPsychDieRoll from './plugins/jspsych-die-roll.js';
import JsPsychCaptcha from './plugins/jspsych-captcha.js';
import html from './utils/html.js';
const total_participants = import.meta.env.VITE_TOTAL_PARTICIPANTS || 2;
const uniqueUsernames = generateUniqueUsernames(total_participants);
const experiment_name = import.meta.env.VITE_EXPERIMENT_NAME; const experiment_name = import.meta.env.VITE_EXPERIMENT_NAME;
let prolific_id; let prolific_id;
let probe_condition; // will be set to neutral or reveal based on the condition let probe_condition; // will be set to ai or human based on the condition.
let debug = false; let debug;
let probe_order; // will be set to ai_first or human_first based on the condition
const CAPTCHA_TRIAL_COUNT = debug ? 2 : 12; // set to 10 for main experiment
const short_version = true; // just using the short version of the task
function delayed_redirect(url) { function delayed_redirect(url) {
setTimeout(() => { setTimeout(() => {
@@ -31,62 +27,104 @@ function delayed_redirect(url) {
} }
const jsPsych = initJsPsych({ const jsPsych = initJsPsych({
on_finish: function () { on_finish: function() {
jsPsych.getDisplayElement().innerHTML = textStimuli.complete; jsPsych.getDisplayElement().innerHTML = textStimuli.complete;
}, },
on_close: function () { on_close: function() {
delayed_redirect(import.meta.env.VITE_CLOSED_URL); delayed_redirect(import.meta.env.VITE_CLOSED_URL);
}, },
on_data_update: function () { on_data_update: function() {
if (debug) { if (debug) {
console.log(jsPsych.data.get().json()); console.log(jsPsych.data.get().json());
} }
}, },
show_progress_bar: true,
}); });
debug = jsPsych.data.getURLVariable('debug') === 'true'; debug =
jsPsych.data.getURLVariable('debug') === 'true' ||
import.meta.env.VITE_DEBUG === 'true';
prolific_id = jsPsych.data.getURLVariable('PROLIFIC_PID'); prolific_id = jsPsych.data.getURLVariable('PROLIFIC_PID');
const COND = Number(jsPsych.data.getURLVariable('C')); const COND = Number(jsPsych.data.getURLVariable('C'));
const probe_preamble = 'In this experiment, you worked together with a partner. '; const probe_closing_text = `<p class ="mt-6">On the next page, you will be asked about your suspicions and thoughts about this aspect of the study.</p>
const probe_closing_text = 'Please share any suspicions you had about your partner during the experiment by indicating your agreement with the following statements.'; <p class="mt-6">Press <span class = "font-semibold"> SPACE</span> to continue</p>`;
const probe_text_neutral = probe_preamble + probe_closing_text; const probe_text_die =
const probe_text_reveal = probe_preamble + 'However, due to the difficulty of implementing multi-participant online studies, your partner was actually an AI agent trained to behave like a human participant. ' + probe_closing_text; `<p> In this study, the die roll to determine captcha difficulty was rigged: the number you rolled was predetermined.</p><p class="mt-2"> Therefore, the difficulty of the captcha task was also predetermined.</p>` +
probe_closing_text;
const probe_text_difficulty =
`In this study, the die roll to determine captcha difficulty was indeed random. </p><p class="mt-2"> However, the difficulty of the captcha task was pre-determined. It was unrelated to the die roll.
</p>` +
probe_closing_text;
let together_colour; const probe_text_baseline = probe_closing_text
const die_result = Math.random() < 0.5 ? 3 : 4;
switch (COND) { switch (COND) {
case 0: case 0:
probe_condition = 'neutral'; probe_condition = 'die';
together_colour = 'blue'; probe_order = 'die_first';
break; break;
case 1: case 1:
probe_condition = 'neutral'; probe_condition = 'die';
together_colour = 'red'; probe_order = 'die_first';
break; break;
case 2: case 2:
probe_condition = 'reveal'; probe_condition = 'difficulty';
together_colour = 'blue'; probe_order = 'die_first';
break; break;
case 3: case 3:
probe_condition = 'reveal'; probe_condition = 'difficulty';
together_colour = 'red'; probe_order = 'die_first';
break;
case 4:
probe_condition = 'die';
probe_order = 'difficulty_first';
break;
case 5:
probe_condition = 'die';
probe_order = 'difficulty_first';
break;
case 6:
probe_condition = 'difficulty';
probe_order = 'difficulty_first';
break;
case 7:
probe_condition = 'difficulty';
probe_order = 'difficulty_first';
break;
case 8:
probe_condition = 'baseline';
probe_order = 'die_first';
break;
case 9:
probe_condition = 'baseline';
probe_order = 'die_first';
break;
case 10:
probe_condition = 'baseline';
probe_order = 'difficulty_first';
break;
case 11:
probe_condition = 'baseline';
probe_order = 'difficulty_first';
break; break;
} }
const stimulusMap = getStimulusMap();
const stimulusMap = getStimulusMap(together_colour);
const props = { const props = {
condition: probe_condition, condition: probe_condition,
together_colour: together_colour,
prolific_id: prolific_id, prolific_id: prolific_id,
experiment_name: experiment_name, experiment_name: experiment_name,
probe_order: probe_order,
cond: COND, cond: COND,
} };
if (debug) { if (debug) {
console.log(props); console.log(props);
@@ -111,49 +149,13 @@ const consent_form = {
type: jsPsychHtmlButtonResponse, type: jsPsychHtmlButtonResponse,
stimulus: stimulusMap.get('consent'), stimulus: stimulusMap.get('consent'),
choices: ['Exit', 'Continue'], choices: ['Exit', 'Continue'],
on_finish: function (data) { on_finish: function(data) {
if (data.response === 0) { if (data.response === 0) {
jsPsych.abortExperiment(stimulusMap.get('no_consent')); jsPsych.abortExperiment(stimulusMap.get('no_consent'));
} }
}, },
}; };
const initial_lobby = {
type: jsPsychLobby,
user_names: uniqueUsernames,
end_number: total_participants,
start_text: 'Searching for a partner',
end_text: 'Partner found, the experiment will begin shortly.',
join_interval: 5000,
show_avatars: false,
};
const lobby_slow = {
type: jsPsychLobby,
user_names: uniqueUsernames,
end_number: total_participants,
start_text: 'Waiting for your partner',
end_text: 'Your partner is ready, the experiment will continue shortly.',
show_avatars: false,
join_interval: 7000,
};
const lobby_fast = {
type: jsPsychLobby,
user_names: uniqueUsernames,
end_number: total_participants,
start_text: 'Waiting for your partner',
end_text: 'Your partner is ready, the experiment will continue shortly.',
show_avatars: false,
join_interval: 500,
};
const multi_user_instructions = {
type: jsPsychHtmlKeyboardResponse,
choices: [' '],
stimulus: stimulusMap.get('multi_user_instructions'),
};
const instructions_1 = { const instructions_1 = {
type: jsPsychHtmlKeyboardResponse, type: jsPsychHtmlKeyboardResponse,
choices: [' '], choices: [' '],
@@ -172,24 +174,6 @@ const instructions_3 = {
stimulus: stimulusMap.get('instructions_3'), stimulus: stimulusMap.get('instructions_3'),
}; };
const instructions_4 = {
type: jsPsychHtmlKeyboardResponse,
choices: [' '],
stimulus: stimulusMap.get('instructions_4'),
};
const pre_practice_instructions = {
type: jsPsychHtmlKeyboardResponse,
choices: [' '],
stimulus: stimulusMap.get('pre_practice_instructions'),
};
const pre_task_instructions = {
type: jsPsychHtmlKeyboardResponse,
choices: [' '],
stimulus: stimulusMap.get('pre_task_instructions'),
};
const debrief = { const debrief = {
type: jsPsychHtmlKeyboardResponse, type: jsPsychHtmlKeyboardResponse,
choices: [' '], choices: [' '],
@@ -202,14 +186,83 @@ const pre_survey_info = {
stimulus: stimulusMap.get('pre_survey_info'), stimulus: stimulusMap.get('pre_survey_info'),
}; };
const survey_function = (survey) => { const die_roll_practice_trial = {
survey.onAfterRenderPage.add(function (sender, options) { type: JsPsychDieRoll,
prompt: html`
<p class="mt-2">Before the real die roll, you can practice here.</p>
<p class="mt-2">
Click the die once to start it rolling, then click it again to stop.
</p>
`,
practice_trial: true,
practice_roll_limit: 3,
roll_duration: 2200,
result_template:
'Practice roll {{roll_number}}/{{roll_limit}}: You rolled a {{value}}.',
};
const die_roll_trial = {
type: JsPsychDieRoll,
prompt: html`
<p class="mt-2">
Click the die once to start it rolling, then click it again to stop.
</p>
`,
rigged_value: die_result,
roll_duration: 2200,
result_template: `You rolled a {{value}} (moderate difficulty). Click 'continue' to proceed.`,
};
const create_captcha_trial = trial_index => {
return {
type: JsPsychCaptcha,
prompt:
'<p class="leading-relaxed text-center">Enter the characters you see into the textbox below.</p>',
difficulty: die_result,
button_label: 'Continue',
error_text: '',
allow_refresh: false,
require_correct: false,
data: {
trial_id: 'captcha_entry',
captcha_index: trial_index + 1,
captcha_total: CAPTCHA_TRIAL_COUNT,
},
};
};
const pre_die_roll_info = {
type: jsPsychHtmlKeyboardResponse,
choices: [' '],
stimulus: `<p>Practice complete. Now you will roll a die to determine the difficulty of the captcha task.</p>
<p class="mt-2">Press <span class ="font-bold">SPACE</span> to continue</p>`,
};
const captcha_trials = Array.from({ length: CAPTCHA_TRIAL_COUNT }, (_, index) =>
create_captcha_trial(index)
);
const die_probe_row = {
text:
'I suspected that the die roll was not random, or the number I received was pre-determined.',
value: 'SuspicionDie',
};
const difficulty_probe_row = {
text:
'I suspected that, contrary to what I was told, the captcha task difficulty was not determined by the die roll.',
value: 'SuspicionDifficulty',
};
const survey_function = survey => {
survey.onAfterRenderPage.add(function(sender, options) {
console.log('Survey page rendered:', sender.currentPage); console.log('Survey page rendered:', sender.currentPage);
if (survey.activePage.name === 'page1') { if (survey.activePage.name === 'page2') {
const nextButton = document.querySelector('#sv-nav-next > div > input'); const nextButton = document.querySelector('#sv-nav-next > div > input');
if (nextButton) { if (nextButton) {
let seconds = 15; let seconds = 20;
const originalText = nextButton.value.replace(/\s*\(\d+\)$/, '') || 'Continue'; const originalText =
nextButton.value.replace(/\s*\(\d+\)$/, '') || 'Continue';
nextButton.disabled = true; nextButton.disabled = true;
nextButton.value = `${originalText} (${seconds})`; nextButton.value = `${originalText} (${seconds})`;
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -224,15 +277,14 @@ const survey_function = (survey) => {
} }
} }
}); });
} };
const survey_1 = {
const survey = {
type: jsPsychSurvey, type: jsPsychSurvey,
survey_function: survey_function, survey_function: survey_function,
survey_json: { survey_json: {
showQuestionNumbers: false, showQuestionNumbers: false,
completeText: 'Done!', completeText: 'Continue',
pageNextText: 'Continue', pageNextText: 'Continue',
pagePrevText: 'Previous', pagePrevText: 'Previous',
showPrevButton: false, showPrevButton: false,
@@ -242,21 +294,19 @@ const survey = {
elements: [ elements: [
{ {
type: 'matrix', type: 'matrix',
name: probe_condition === 'neutral' ? probe_text_neutral : probe_text_reveal, name:
'Please answer the following questions about your experience in the captcha task.',
alternateRows: true, alternateRows: true,
isAllRowRequired: debug ? false : true, isAllRowRequired: debug ? false : true,
rowOrder: 'random',
rows: [ rows: [
{ {
text: 'I believed that my partner was actually an AI agent or a bot.', text: `I felt lucky during the die roll.`,
value: 'SuspicionPartner', value: 'Luck',
}, },
{ {
text: `There's no question here. Select 'Disagree' to show that you're paying attention.`, text: `I felt unlucky during the die roll.`,
value: 'AttentionCheck', value: 'Bad_luck',
},
{
text: 'I believed that my partner was another, human, participant.',
value: 'ConfidencePartner',
}, },
], ],
columns: [ columns: [
@@ -284,8 +334,153 @@ const survey = {
}, },
], ],
}, },
{ {
name: 'page2', name: 'page2',
elements: [
{
type: 'matrix',
name:
'Please answer the following questions about your experience in the captcha task.',
alternateRows: true,
isAllRowRequired: debug ? false : true,
rowOrder: 'random',
rows: [
{
text: `I found the captcha task difficult.`,
value: 'Difficulty',
},
{
text: `I think I solved all the captchas correctly.`,
value: 'Accuracy',
},
{
text: `I think I solved captchas faster than most other participants would have.`,
value: 'Relative_performance',
},
{
text: `I think I solved captchas slower than most other participants would have.`,
value: 'Relative_performance_slow',
},
],
columns: [
{
value: 5,
text: 'Strongly agree',
},
{
value: 4,
text: 'Agree',
},
{
value: 3,
text: 'Neutral',
},
{
value: 2,
text: 'Disagree',
},
{
value: 1,
text: 'Strongly disagree',
},
],
},
],
},
],
},
};
const pre_manip_info = {
type: jsPsychHtmlKeyboardResponse,
choices: [' '],
stimulus: function () {
switch (probe_condition) {
case 'die':
return probe_text_die;
case 'difficulty':
return probe_text_difficulty;
case 'baseline':
return probe_text_baseline;
default:
return 'ERROR: probe condition not recognized';
}
},
};
const survey_2 = {
type: jsPsychSurvey,
survey_function: survey_function,
survey_json: {
showQuestionNumbers: false,
completeText: 'Done!',
pageNextText: 'Continue',
pagePrevText: 'Previous',
showPrevButton: false,
pages: [
{
name: 'page1',
elements: [
{
type: 'matrix',
name: "Please indicate your agreement with the following statements.",
alternateRows: true,
isAllRowRequired: debug ? false : true,
rowOrder: 'random',
rows: [
probe_order === 'die_first'
? die_probe_row
: difficulty_probe_row,
{
text: `There's no question here. Select 'Disagree' to show that you're paying attention.`,
value: 'AttentionCheck',
},
probe_order === 'die_first'
? difficulty_probe_row
: die_probe_row,
],
columns: [
{
value: 5,
text: 'Strongly agree',
},
{
value: 4,
text: 'Agree',
},
{
value: 3,
text: 'Neutral',
},
{
value: 2,
text: 'Disagree',
},
{
value: 1,
text: 'Strongly disagree',
},
],
},
],
},
{
name: 'page2',
elements: [
{
type: 'comment',
title: `Please write a sentence or two on what you thought the study was about.`,
isRequired: debug == true ? false : true,
},
{
type: 'comment',
title: ` Indicate any other thoughts or suspicions you had about the study. \n`,
isRequired: debug == true ? false : true,
}
],
},
{
name: 'page3',
elements: [ elements: [
{ {
type: 'radiogroup', type: 'radiogroup',
@@ -314,81 +509,36 @@ const survey = {
defaultValue: 18, defaultValue: 18,
}, },
], ],
} },
], ],
}, },
}; };
const factors = { const main_experiment_timeline = [
selector: ['partner', 'participant'], instructions_1,
together_side: ['left', 'right'], instructions_2,
location: short_version ? [33, 50, 66] : [32, 33, 34, 49, 50, 51, 65, 66, 67], die_roll_practice_trial,
}; pre_die_roll_info,
die_roll_trial,
instructions_3,
...captcha_trials,
pre_survey_info,
survey_1,
pre_manip_info,
survey_2,
debrief,
];
const practice_factors = {
selector: ['partner', 'participant'],
together_side: ['left', 'right'],
location: [50],
};
const trials = jsPsych.randomization.factorial(factors, 1);
if (debug) { if (debug) {
console.log(factors); timeline.push(die_roll_trial);
console.log(trials); timeline.push(...main_experiment_timeline);
}
const practice_trials = jsPsych.randomization.factorial(practice_factors, 1);
const object_moving_trials = {
timeline: [
{
type: jsPsychObjectMoving,
selector: jsPsych.timelineVariable('selector'),
together_side: jsPsych.timelineVariable('together_side'),
location: jsPsych.timelineVariable('location'),
together_colour: together_colour,
},
],
timeline_variables: trials,
};
const object_moving_practice = {
timeline: [
{
type: jsPsychObjectMoving,
selector: jsPsych.timelineVariable('selector'),
together_side: jsPsych.timelineVariable('together_side'),
location: jsPsych.timelineVariable('location'),
together_colour: together_colour,
},
],
timeline_variables: practice_trials,
};
if (debug) {
timeline.push(survey)
timeline.push(debrief);
} }
if (!debug) { if (!debug) {
timeline.push(pre_consent_info); timeline.push(pre_consent_info);
timeline.push(consent_form);
timeline.push(enter_fullscreen); timeline.push(enter_fullscreen);
timeline.push(multi_user_instructions); timeline.push(consent_form);
timeline.push(initial_lobby); timeline.push(...main_experiment_timeline);
timeline.push(instructions_1);
timeline.push(instructions_2);
timeline.push(instructions_3);
timeline.push(instructions_4);
timeline.push(pre_practice_instructions);
timeline.push(lobby_slow);
timeline.push(object_moving_practice);
timeline.push(pre_task_instructions);
timeline.push(lobby_fast);
timeline.push(object_moving_trials);
timeline.push(pre_survey_info);
timeline.push(survey);
timeline.push(debrief);
} }
jsPsych.run(timeline); jsPsych.run(timeline);

303
plugins/jspsych-captcha.js Normal file
View File

@@ -0,0 +1,303 @@
import { ParameterType } from 'jspsych';
const info = {
name: 'captcha',
parameters: {
prompt: {
type: ParameterType.HTML_STRING,
default: '',
},
difficulty: {
type: ParameterType.INT,
default: 3,
},
button_label: {
type: ParameterType.STRING,
default: 'Submit',
},
allow_refresh: {
type: ParameterType.BOOL,
default: true,
},
require_correct: {
type: ParameterType.BOOL,
default: true,
},
error_text: {
type: ParameterType.HTML_STRING,
default: 'That entry did not match, please try again.',
},
},
};
class CaptchaPlugin {
constructor(jsPsych) {
this.jsPsych = jsPsych;
this.characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
}
trial(display_element, trial) {
const start_time = performance.now();
const attempt_counts = { submission_count: 0, refresh_count: 0 };
const difficulty_settings = this.resolve_difficulty(trial.difficulty);
let captcha_text = this.generate_text(difficulty_settings.length);
let last_target_at_submission = captcha_text;
display_element.innerHTML = this.build_markup(
trial.prompt,
trial.button_label,
trial.allow_refresh
);
const canvas_element = display_element.querySelector(
'.jspsych_captcha_canvas'
);
const canvas_context = canvas_element.getContext('2d');
const input_element = display_element.querySelector(
'.jspsych_captcha_input'
);
const button_element = display_element.querySelector(
'.jspsych_captcha_submit'
);
const refresh_button = display_element.querySelector(
'.jspsych_captcha_refresh'
);
const error_element = display_element.querySelector(
'.jspsych_captcha_error'
);
const draw_captcha = () => {
this.draw_captcha(canvas_context, captcha_text, difficulty_settings);
};
draw_captcha();
input_element.focus();
if (refresh_button) {
refresh_button.addEventListener('click', () => {
attempt_counts.refresh_count += 1;
captcha_text = this.generate_text(difficulty_settings.length);
draw_captcha();
error_element.textContent = '';
input_element.value = '';
input_element.focus();
});
}
const length_hint_element = display_element.querySelector(
'.jspsych_captcha_length_hint'
);
const update_button_state = () => {
const current_length = input_element.value.trim().length;
const required_length = captcha_text.length;
button_element.disabled = current_length < required_length;
if (length_hint_element) {
length_hint_element.textContent =
current_length < required_length
? `Type ${required_length - current_length} more character(s) to continue.`
: '';
}
};
const finish_trial = (response_value, is_correct) => {
const trial_data = {
rt: performance.now() - start_time,
response: response_value,
participant_entry: response_value,
participant_entry_length: response_value.length,
captcha_target: last_target_at_submission,
correct: is_correct,
submission_count: attempt_counts.submission_count,
manual_refreshes: attempt_counts.refresh_count,
difficulty_label: difficulty_settings.label,
difficulty_length: difficulty_settings.length,
difficulty_level: difficulty_settings.level,
};
display_element.innerHTML = '';
this.jsPsych.finishTrial(trial_data);
};
const validate_response = () => {
attempt_counts.submission_count += 1;
const response_value = input_element.value.trim().toUpperCase();
last_target_at_submission = captcha_text;
const is_correct = response_value === captcha_text;
if (response_value.length < captcha_text.length) {
error_element.textContent = `Please enter all ${captcha_text.length} characters shown in the captcha.`;
update_button_state();
return;
}
if (is_correct || !trial.require_correct) {
finish_trial(response_value, is_correct);
return;
}
error_element.textContent = trial.error_text;
captcha_text = this.generate_text(difficulty_settings.length);
draw_captcha();
input_element.value = '';
input_element.focus();
};
button_element.addEventListener('click', validate_response);
input_element.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
validate_response();
}
});
input_element.addEventListener('input', update_button_state);
update_button_state();
}
build_markup(prompt, button_label, allow_refresh) {
const prompt_html = prompt
? `<div class="jspsych_captcha_prompt">${prompt}</div>`
: '';
const refresh_button = allow_refresh
? '<button type="button" class="jspsych_captcha_refresh" aria-label="Get a new captcha">↻</button>'
: '';
return `
<div class="jspsych_captcha">
${prompt_html}
<div class="jspsych_captcha_canvas_wrapper">
<canvas class="jspsych_captcha_canvas" width="260" height="110" role="img" aria-label="Captcha"></canvas>
${refresh_button}
</div>
<label class="jspsych_captcha_label" aria-label="Enter the characters you see into the textbox below.">
<input type="text" autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false" class="jspsych_captcha_input" />
<span class="jspsych_captcha_length_hint"></span>
</label>
<button type="button" class="jspsych-btn jspsych_captcha_submit">${button_label}</button>
<div class="jspsych_captcha_error" role="status" aria-live="polite"></div>
</div>
`;
}
draw_captcha(canvas_context, captcha_text, settings) {
const { width, height } = canvas_context.canvas;
canvas_context.clearRect(0, 0, width, height);
const gradient = canvas_context.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#f8fafc');
gradient.addColorStop(1, '#e2e8f0');
canvas_context.fillStyle = gradient;
canvas_context.fillRect(0, 0, width, height);
for (let index = 0; index < settings.noise_lines; index++) {
canvas_context.beginPath();
canvas_context.moveTo(Math.random() * width, Math.random() * height);
canvas_context.bezierCurveTo(
Math.random() * width,
Math.random() * height,
Math.random() * width,
Math.random() * height,
Math.random() * width,
Math.random() * height
);
canvas_context.lineWidth = 1 + Math.random() * 2;
canvas_context.strokeStyle = `rgba(30, 41, 59, ${0.15 +
Math.random() * 0.2})`;
canvas_context.stroke();
}
canvas_context.font = `bold ${settings.font_size}px 'Courier New', monospace`;
canvas_context.textBaseline = 'middle';
canvas_context.textAlign = 'center';
const horizontal_step = width / (captcha_text.length + 1);
const center_y = height / 2;
for (let index = 0; index < captcha_text.length; index++) {
const current_character = captcha_text[index];
canvas_context.save();
const jitter_y = this.random_between(-settings.jitter, settings.jitter);
canvas_context.translate(
horizontal_step * (index + 1),
center_y + jitter_y
);
canvas_context.rotate(
this.random_between(-settings.rotation, settings.rotation)
);
canvas_context.fillStyle = '#000000';
canvas_context.fillText(current_character, 0, 0);
canvas_context.restore();
}
const noise_pixels = Math.floor(width * height * settings.speckle_density);
const image_data = canvas_context.getImageData(0, 0, width, height);
for (let index = 0; index < noise_pixels; index++) {
const pixel_offset =
(Math.floor(Math.random() * width) +
Math.floor(Math.random() * height) * width) *
4;
const shade = Math.random() > 0.5 ? 255 : 0;
image_data.data[pixel_offset] = shade;
image_data.data[pixel_offset + 1] = shade;
image_data.data[pixel_offset + 2] = shade;
image_data.data[pixel_offset + 3] = 255;
}
canvas_context.putImageData(image_data, 0, 0);
}
generate_text(length) {
let generated_text = '';
for (let index = 0; index < length; index++) {
generated_text += this.characters.charAt(
Math.floor(Math.random() * this.characters.length)
);
}
return generated_text;
}
resolve_difficulty(difficulty_input) {
const default_level = 3;
let normalized_level = Number(difficulty_input);
if (!Number.isFinite(normalized_level)) {
normalized_level = default_level;
}
normalized_level = Math.round(Math.min(6, Math.max(1, normalized_level)));
const base_length = 7;
const base_font_size = 20;
const base_rotation = 0.25;
const max_rotation = base_rotation * 3;
const base_noise_lines = 4;
const base_speckle_density = 0.01;
const base_jitter = 25;
const noise_line_step = 0.8;
const speckle_step = 0.0015;
const jitter_step = 1.6;
const rotation_step = (max_rotation - base_rotation) / 5;
const rotation = base_rotation + rotation_step * (normalized_level - 1);
const noise_lines = Math.round(
base_noise_lines + noise_line_step * (normalized_level - 1)
);
const speckle_density =
base_speckle_density + speckle_step * (normalized_level - 1);
const jitter = base_jitter + jitter_step * (normalized_level - 1);
return {
length: base_length,
noise_lines: noise_lines,
rotation: rotation,
jitter: jitter,
font_size: base_font_size,
speckle_density: speckle_density,
label: `level_${normalized_level}`,
level: normalized_level,
};
}
random_between(min, max) {
return Math.random() * (max - min) + min;
}
}
CaptchaPlugin.info = info;
export default CaptchaPlugin;

346
plugins/jspsych-die-roll.js Normal file
View File

@@ -0,0 +1,346 @@
import { ParameterType } from 'jspsych';
const info = {
name: 'die-roll',
parameters: {
prompt: {
type: ParameterType.HTML_STRING,
default: '',
},
rigged_value: {
type: ParameterType.INT,
default: null,
},
roll_duration: {
type: ParameterType.INT,
default: 2000,
},
animation_interval: {
type: ParameterType.INT,
default: 120,
},
button_label: {
type: ParameterType.STRING,
default: 'Continue',
},
result_template: {
type: ParameterType.STRING,
default: 'You rolled a {{value}}.',
},
practice_trial: {
type: ParameterType.BOOL,
default: false,
},
practice_roll_limit: {
type: ParameterType.INT,
default: 5,
},
},
};
class DieRollPlugin {
constructor(jsPsych) {
this.jsPsych = jsPsych;
}
trial(display_element, trial) {
const start_time = performance.now();
const practice_enabled = Boolean(trial.practice_trial);
const practice_roll_limit = practice_enabled
? this.get_practice_roll_limit(trial.practice_roll_limit)
: 1;
const sanitized_rigged_value = practice_enabled
? null
: this.get_rigged_value(trial.rigged_value);
const get_next_result_value = () =>
sanitized_rigged_value ?? this.get_random_value();
let pending_final_value = get_next_result_value();
let last_roll_value = null;
display_element.innerHTML = this.build_markup(
trial.prompt,
trial.button_label
);
const face_element = display_element.querySelector(
'.jspsych_die_roll_face'
);
const result_element = display_element.querySelector(
'.jspsych_die_roll_result'
);
const button_element = display_element.querySelector(
'.jspsych_die_roll_button'
);
const PRACTICE_UNLOCK_DELAY_MS = 600;
const MIN_IDLE_CLICK_INTERVAL_MS = 350;
let is_rolling = false;
let has_rolled = false;
let roll_started_at = null;
let roll_stopped_at = null;
let die_click_count = 0;
let practice_rolls_completed = 0;
let practice_reenable_timeout = null;
let previous_face_value = null;
let last_face_interaction_at = null;
const clear_timeouts = () => {
if (practice_enabled && practice_reenable_timeout !== null) {
window.clearTimeout(practice_reenable_timeout);
practice_reenable_timeout = null;
}
this.jsPsych.pluginAPI.clearAllTimeouts();
};
const lock_die_face = () => {
face_element.classList.remove('jspsych_die_roll_face--interactive');
face_element.classList.remove('jspsych_die_roll_face--active');
face_element.classList.add('jspsych_die_roll_face--locked');
face_element.setAttribute('aria-disabled', 'true');
face_element.removeAttribute('tabindex');
face_element.style.pointerEvents = 'none';
};
const make_die_interactive = () => {
face_element.classList.remove('jspsych_die_roll_face--locked');
face_element.classList.add('jspsych_die_roll_face--interactive');
face_element.setAttribute('tabindex', '0');
face_element.removeAttribute('aria-disabled');
face_element.style.pointerEvents = '';
};
const get_next_face_value = () => {
let value;
do {
value = Math.floor(Math.random() * 6) + 1;
} while (
value === previous_face_value ||
(sanitized_rigged_value !== null && value === sanitized_rigged_value)
);
previous_face_value = value;
return value;
};
const cycle_face = () => {
if (!is_rolling) {
return;
}
const value = get_next_face_value();
face_element.textContent = value;
this.jsPsych.pluginAPI.setTimeout(cycle_face, trial.animation_interval);
};
const finalize_roll = () => {
if (!is_rolling || has_rolled) {
return;
}
is_rolling = false;
has_rolled = true;
die_click_count += 1;
roll_stopped_at = performance.now();
clear_timeouts();
last_roll_value = pending_final_value;
face_element.textContent = last_roll_value;
lock_die_face();
practice_rolls_completed += 1;
const rolls_remaining = Math.max(
practice_roll_limit - practice_rolls_completed,
0
);
const formatted_result = this.render_result_text(
trial.result_template,
{
value: last_roll_value,
roll_number: practice_rolls_completed,
roll_limit: practice_enabled ? practice_roll_limit : null,
rolls_remaining: practice_enabled ? rolls_remaining : null,
}
);
if (practice_enabled) {
if (rolls_remaining > 0) {
result_element.innerHTML =
`${formatted_result} <p class = "mt-2"> You may roll again (${rolls_remaining} ` +
`practice ${rolls_remaining === 1 ? 'roll' : 'rolls'} remaining) or click 'Continue' to proceed.</p>`;
// Brief delay prevents a double-click from immediately starting a new roll.
practice_reenable_timeout = window.setTimeout(() => {
make_die_interactive();
has_rolled = false;
practice_reenable_timeout = null;
}, PRACTICE_UNLOCK_DELAY_MS);
} else {
result_element.textContent =
`${formatted_result} Practice complete. Click 'Continue' to proceed.`;
}
} else {
result_element.textContent = formatted_result;
}
button_element.disabled = false;
if (!practice_enabled || rolls_remaining === 0) {
button_element.focus();
}
};
const begin_roll = () => {
if (is_rolling || has_rolled) {
return;
}
previous_face_value = null;
pending_final_value = get_next_result_value();
is_rolling = true;
die_click_count += 1;
roll_started_at = performance.now();
result_element.textContent = 'Rolling... click again to stop the die.';
face_element.classList.add('jspsych_die_roll_face--active');
button_element.disabled = true;
clear_timeouts();
cycle_face();
};
const handle_face_interaction = event => {
const event_type = event?.type ?? 'unknown';
if (
(event_type === 'pointerdown' || event_type === 'click') &&
typeof event?.button === 'number' &&
event.button !== 0
) {
return;
}
if (practice_enabled) {
const now = performance.now();
if (!is_rolling && last_face_interaction_at !== null) {
const elapsed = now - last_face_interaction_at;
if (elapsed < MIN_IDLE_CLICK_INTERVAL_MS) {
event?.preventDefault();
return;
}
}
last_face_interaction_at = now;
}
if (is_rolling) {
finalize_roll();
return;
}
if (!has_rolled) {
begin_roll();
return;
}
};
button_element.disabled = true;
face_element.textContent = '';
result_element.innerHTML = practice_enabled
? `<p>Practice rolling the die. You can complete up to ${practice_roll_limit}</p>` +
`practice ${practice_roll_limit === 1 ? 'roll' : 'rolls'}. <p class = "mt-2"> Click the die to start, then click it again to stop. </p>`
: 'Click the die to start, then click it again to lock in your number.';
make_die_interactive();
face_element.setAttribute('role', 'button');
face_element.setAttribute(
'aria-label',
'Virtual die. Click to start and stop.'
);
const face_interaction_event =
typeof window.PointerEvent === 'function' ? 'pointerdown' : 'click';
face_element.addEventListener(face_interaction_event, handle_face_interaction);
face_element.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handle_face_interaction(event);
}
});
const end_trial = () => {
is_rolling = false;
clear_timeouts();
const trial_data = {
roll: last_roll_value,
rigged_value: sanitized_rigged_value,
rigged: sanitized_rigged_value !== null,
die_click_count: die_click_count,
roll_started_after_ms: roll_started_at
? roll_started_at - start_time
: null,
roll_duration_ms:
roll_started_at && roll_stopped_at
? roll_stopped_at - roll_started_at
: null,
rt: performance.now() - start_time,
practice_trial: practice_enabled,
practice_roll_limit: practice_enabled ? practice_roll_limit : null,
practice_rolls_completed: practice_enabled
? practice_rolls_completed
: null,
practice_rolls_remaining: practice_enabled
? Math.max(practice_roll_limit - practice_rolls_completed, 0)
: null,
};
display_element.innerHTML = '';
this.jsPsych.finishTrial(trial_data);
};
button_element.addEventListener('click', () => {
if (!button_element.disabled) {
end_trial();
}
});
}
build_markup(prompt, button_label) {
const prompt_html = prompt
? `<div class="jspsych_die_roll_prompt">${prompt}</div>`
: '';
return `
<div class="jspsych_die_roll">
${prompt_html}
<div class="jspsych_die_roll_face">?</div>
<div class="jspsych_die_roll_result" aria-live="polite"></div>
<button class="jspsych-btn jspsych_die_roll_button" type="button">${button_label}</button>
</div>
`;
}
get_random_value() {
return this.jsPsych.randomization.sampleWithoutReplacement(
[1, 2, 3, 4, 5, 6],
1
)[0];
}
get_rigged_value(value) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= 6) {
return Math.round(numeric);
}
return null;
}
get_practice_roll_limit(value) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 1) {
return Math.round(numeric);
}
return 5;
}
render_result_text(template, replacements) {
if (typeof template !== 'string' || template.length === 0) {
return '';
}
return Object.entries(replacements).reduce((text, [key, value]) => {
if (value === null || value === undefined) {
return text;
}
const pattern = new RegExp(`\\{\\{${key}\\}\\}`, 'gi');
return text.replace(pattern, value);
}, template);
}
}
DieRollPlugin.info = info;
export default DieRollPlugin;

View File

@@ -1,27 +0,0 @@
export default function getColourMap(together_colour) {
const colourMap = new Map();
// Some reduncancy here, but Tailwind wont recognise the classes otherwise
colourMap.set('go', 'text-lime-600');
colourMap.set('warning', 'text-yellow-600');
colourMap.set('self-username', 'text-sky-600');
colourMap.set('success', 'text-green-600');
colourMap.set('gobg', 'bg-lime-600');
colourMap.set('object-enabled', 'bg-gray-600');
colourMap.set('object-disabled', 'bg-gray-400');
if (together_colour === 'blue') {
colourMap.set('alone', 'text-rose-400');
colourMap.set('together', 'text-blue-400');
colourMap.set('alonebg', 'bg-rose-400');
colourMap.set('togetherbg', 'bg-blue-400');
} else if (together_colour === 'red') {
colourMap.set('alone', 'text-blue-400');
colourMap.set('together', 'text-rose-400');
colourMap.set('alonebg', 'bg-blue-400');
colourMap.set('togetherbg', 'bg-rose-400');
}
return colourMap;
}

View File

@@ -1,77 +0,0 @@
function generateRandomUsername(usedAdjectives, usedNouns) {
const adjectives = [
'Round',
'Square',
'Tall',
'Wide',
'Small',
'Large',
'Flat',
'Smooth',
'Long',
'Short',
'Straight',
'Curved',
'Solid',
'Hollow',
'Dense',
'Light',
'Deep',
'Broad',
'Thin',
'Thick',
];
const nouns = [
'Table',
'Chair',
'Lamp',
'Spoon',
'Book',
'Door',
'Window',
'Pencil',
'Paper',
'Bowl',
'Plate',
'Cup',
'Box',
'Shelf',
'Frame',
'Desk',
'Mirror',
'Basket',
'Button',
'Bottle',
];
let randomAdjective;
do {
randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)];
} while (usedAdjectives.has(randomAdjective));
let randomNoun;
do {
randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
} while (usedNouns.has(randomNoun));
usedAdjectives.add(randomAdjective);
usedNouns.add(randomNoun);
const randomInt = Math.floor(Math.random() * 100);
return randomAdjective + randomNoun + randomInt;
}
function generateUniqueUsernames(count) {
const usernames = new Set();
const usedAdjectives = new Set();
const usedNouns = new Set();
while (usernames.size < count) {
usernames.add(generateRandomUsername(usedAdjectives, usedNouns));
}
return Array.from(usernames);
}
export default generateUniqueUsernames;

View File

@@ -1,179 +0,0 @@
import { ParameterType } from 'jspsych';
import { html } from './text-stimuli';
import getColourMap from './colours';
const info = {
name: 'lobby',
version: "1.0",
parameters: {
user_names: {
type: ParameterType.STRING,
pretty_name: 'User names',
default: [],
array: true,
},
start_text: {
type: ParameterType.STRING,
pretty_name: 'Start text',
},
end_text: {
type: ParameterType.STRING,
pretty_name: 'End text',
},
start_number: {
type: ParameterType.INT,
pretty_name: 'Start number',
default: null,
},
end_number: {
type: ParameterType.INT,
pretty_name: 'End number',
default: 3,
},
join_interval: {
type: ParameterType.INT,
pretty_name: 'Join interval',
default: 5000,
},
user_object: {
type: ParameterType.OBJECT,
pretty_name: 'User object',
default: {},
},
show_avatars: {
type: ParameterType.BOOL,
pretty_name: 'Show avatar',
default: false,
},
data: {
type: ParameterType.OBJECT,
pretty_name: 'Data',
default: {},
},
},
// prettier-ignore
citations: '__CITATIONS__'
};
class jsPsychLobby {
constructor(jsPsych) {
this.jsPsych = jsPsych;
}
static {
this.info = info;
}
trial(display_element, trial) {
let current_participants;
if (!trial.start_number) {
current_participants = Math.floor(Math.random() * trial.end_number) + 1;
} else {
current_participants = trial.start_number;
}
const colourMap = getColourMap(' ');
display_element.innerHTML = html`<div id="search-text" class="mb-4"></div>
<div id="participants-list"></div>
<div id="loading-animation" class="loading"></div>
<div id="refresh-warning" class="mt-4"></div>`;
const search_text_div = document.getElementById('search-text');
const participants_list_div = document.getElementById('participants-list');
const refresh_warning_div = document.getElementById('refresh-warning');
const loading_animation_div = document.getElementById('loading-animation');
search_text_div.innerHTML =
'<p class="text-lg font-semibold" id="search-text-p">' +
trial.start_text +
' (<span id="current-participants">' +
current_participants +
'</span>/' +
trial.end_number +
') ' +
'</p>';
refresh_warning_div.innerHTML =
`<p class="text-sm font-semibold ${colourMap.get('warning')}">Please do not refresh or close this page unless you are not connected to your partner within 5 minutes</p>`;
const current_participants_span = document.getElementById('current-participants');
let participants_list = trial.user_names.slice(1, current_participants);
let ended = false;
const avatar_svg_template = colour => html`
<svg
xmlns="http://www.w3.org/2000/svg"
fill="${colour}"
viewBox="0 0 24 24"
stroke-width="0.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg>
`;
const createParticipantsHtml = () => {
let html = '';
for (let i = 0; i < participants_list.length; i++) {
/* if (trial.show_avatars) {
html += `<div style="width: 24px; height: 24px;">${avatar_svg_template(
trial.user_object[participants_list[i]]
)}</div>`;
} */
if (participants_list[i] === trial.user_names[0]) {
html += `<p class="text-base ${colourMap.get('self-username')}">` + participants_list[i] + ` (you)</p>`;
} else {
html += `<p class="text-base">` + participants_list[i] + ` </p>`;
}
}
return (html);
};
participants_list_div.innerHTML = createParticipantsHtml();
setTimeout(() => {
participants_list.push(trial.user_names[0]);
participants_list_div.innerHTML = createParticipantsHtml();
}, 100);
const updateParticipants = () => {
if (current_participants >= trial.end_number && !ended) {
ended = true;
clearInterval(participants_interval);
setTimeout(this.foundParticipants, 1500);
} else {
participants_list.push(trial.user_names[current_participants]);
participants_list_div.innerHTML = createParticipantsHtml();
current_participants++;
if (current_participants <= trial.end_number && !ended) {
current_participants_span.innerHTML = current_participants;
}
}
};
this.foundParticipants = () => {
participants_list_div.innerHTML = html`<span class="${colourMap.get('success')} font-semibold">${trial.end_text}</span>`;
loading_animation_div.classList.remove('loading');
setTimeout(() => {
this.jsPsych.finishTrial();
}, 2000);
};
const participants_interval = setInterval(
updateParticipants,
trial.join_interval
);
}
}
export default jsPsychLobby;

View File

@@ -1,302 +0,0 @@
import { ParameterType } from 'jspsych';
import { html } from './text-stimuli';
import getColourMap from './colours';
const info = {
name: 'object-moving',
version: "1.1",
parameters: {
selector: {
type: ParameterType.STRING,
pretty_name: 'Selector',
},
together_side: {
type: ParameterType.STRING,
pretty_name: 'together side',
},
location: {
type: ParameterType.INT,
pretty_name: 'Location',
},
together_colour: {
type: ParameterType.STRING,
pretty_name: 'Together colour',
},
},
// prettier-ignore
citations: '__CITATIONS__'
};
class jsPsychObjectMoving {
constructor(jsPsych) {
this.jsPsych = jsPsych;
}
static {
this.info = info;
}
trial(display_element, trial) {
const colourMap = getColourMap(trial.together_colour);
const left_goal = trial.together_side === 'left' ? 'together' : 'alone';
const right_goal = trial.together_side === 'left' ? 'alone' : 'together';
const left_colour = trial.together_side === 'left' ? colourMap.get('togetherbg') : colourMap.get('alonebg');
const right_colour = trial.together_side === 'left' ? colourMap.get('alonebg') : colourMap.get('togetherbg');
display_element.innerHTML = html`
<div class="flex flex-row min-w-screen absolute top-0 left-0 min-h-screen">
<div id="${left_goal}-goal" class="w-1/12 ${left_colour}"></div>
<div id="workspace" class="w-10/12"></div>
<div id="${right_goal}-goal" class="w-1/12 ${right_colour}"></div>
<div id="info-box" class="z-10 absolute bottom-1/40 min-w-screen justify-center items-center"></div>
</div>
`;
const workspace = document.getElementById('workspace');
const info_box = document.getElementById('info-box');
const workspace_width = workspace.clientWidth;
const viewport_width = window.visualViewport.width;
const goal_offset = viewport_width * (1/12);
const box_width = viewport_width * (3/100);
const workspace_offset = workspace_width * trial.location/100;
const offset_left = workspace_offset + goal_offset - box_width/2;
const viewport_height = window.visualViewport.height;
const top_offset = viewport_height * .5;
const separation = box_width * 1.5;
const objects = html`
<div id="object-partner" class="object-moving-box ${colourMap.get('object-disabled')} absolute z-10" style="left: ${offset_left}px; top: ${top_offset - separation}px"></div>
<div id="object-participant" class="object-moving-box ${colourMap.get('object-disabled')} absolute z-10" style="left: ${offset_left}px; top: ${top_offset + separation}px"></div>
`;
workspace.innerHTML = objects;
const object_partner = document.getElementById('object-partner');
const object_participant = document.getElementById('object-participant');
object_partner.addEventListener('click', () => issue_alert('partner_click'));
object_participant.addEventListener('click', () => issue_alert('early_click_choice'));
const partner_turn = html`<span class="font-semi-bold text-gray-800 text-xl">Awaiting your partner's selection</span>
<div class='loading'></div>`
let choice;
let go_clicked = false;
let partner_finished = false;
let participant_finished = false;
let partner_choice_made = false;
const jsPsych = this.jsPsych;
let start_time = 0;
let response_time = 0;
if (trial.selector === 'participant') {
display_choices();
} else {
info_box.innerHTML = partner_turn;
const partner_choice_delay = jsPsych.randomization.sampleExGaussian(2000, 250, 1/100, true);
let random = Math.random();
//These probabilities are based on Azaad and Sebanz (2025)
if (trial.location > 48 && trial.location < 52) {
choice = random < 0.64 ? 'together' : 'alone';
} else if (trial.location > 31 && trial.location < 35) {
choice = random < 0.58 ? 'together' : 'alone';
} else if (trial.location > 64 && trial.location < 68) {
choice = random < 0.67 ? 'together' : 'alone';
}
setTimeout(() => {
info_box.innerHTML = '';
clear_boxes();
display_go();
initiate_partner_action();
}, partner_choice_delay);
}
function display_choices(){
info_box.innerHTML = html`<div class="flex flex-row justify-center items-center gap-2">
<div id="${left_goal}-button" class="w-1/12 py-1 text-slate-50 ${left_colour}">${left_goal.charAt(0).toUpperCase() + left_goal.slice(1)}</div>
<div id="${right_goal}-button" class="w-1/12 py-1 text-slate-50 ${right_colour}">${right_goal.charAt(0).toUpperCase() + right_goal.slice(1)}</div>
</div>`;
const left_goal_button = document.getElementById(`${left_goal}-button`);
const right_goal_button = document.getElementById(`${right_goal}-button`);
start_time = Date.now();
left_goal_button.addEventListener('click', () => {
choice = left_goal;
handle_choice_made(left_goal_button,right_goal_button);
});
right_goal_button.addEventListener('click', () => {
choice = right_goal;
handle_choice_made(left_goal_button,right_goal_button);
});
}
function handle_choice_made(left_goal_button,right_goal_button){
left_goal_button.removeEventListener('click', handle_choice_made);
right_goal_button.removeEventListener('click', handle_choice_made);
left_goal_button.remove();
right_goal_button.remove();
clear_boxes();
display_go();
response_time = Date.now() - start_time;
if (choice === 'together') {
initiate_partner_action();
}
}
function initiate_partner_action(){
const go_delay = trial.selector === 'participant' ? 1400 : 1000
const partner_go_delay = jsPsych.randomization.sampleExGaussian(go_delay, 500, 1/100, true);
const partner_teleport_delay = jsPsych.randomization.sampleExGaussian(1400, 300, 1/100, true);
setTimeout(() => {
change_object_colour(object_partner);
partner_choice_made = true;
}, partner_go_delay);
setTimeout(() => {
teleport_object(object_partner);
partner_finished = true;
check_finished();
}, partner_teleport_delay + partner_go_delay);
}
function clear_boxes() {
if (choice === 'together') {
return;
}
if (trial.selector === 'participant') {
object_partner.remove();
} else {
object_participant.remove();
}
}
let go_button;
function display_go() {
if (choice === 'alone' && trial.selector === 'partner') {
return;
}
info_box.innerHTML = html`<div id="go-button" class="mx-auto font-semi-bold ${colourMap.get('gobg')} text-slate-50 text-xl w-1/20 p-2">Go</div>`;
go_button = document.getElementById('go-button');
object_participant.addEventListener('click', () => issue_alert('early_click_go'));
go_button.addEventListener('click', () => handle_go_click(go_button));
}
function handle_go_click() {
go_button.removeEventListener('click', handle_go_click);
go_button.removeEventListener('click', () => issue_alert('early_click_go'));
go_clicked = true;
go_button.remove();
change_object_colour(object_participant);
object_participant.addEventListener('click', () => handle_teleport_click());
}
function handle_teleport_click(){
teleport_object(object_participant);
participant_finished = true;
check_finished();
}
function change_object_colour(object){
object.classList.remove(colourMap.get('object-disabled'));
object.classList.add(colourMap.get('object-enabled'));
}
function teleport_object(object){
let goal_side = 'left';
if (choice === 'together' && trial.together_side === 'right') {
goal_side = 'right';
}
if (choice === 'alone' && trial.together_side === 'left') {
goal_side = 'right';
}
const mid_goal_offset = viewport_width * (1/24);
const new_left_offset = goal_side === 'left' ? mid_goal_offset - box_width/2 : viewport_width - mid_goal_offset - box_width/2;
object.style.transition = 'left 0.35s';
object.style.left = `${new_left_offset}px`;
}
function check_finished(){
let finished = false;
if (choice === 'together') {
if (partner_finished && participant_finished) {
finished = true;
}
}
if (choice === 'alone') {
if (partner_finished || participant_finished) {
finished = true;
}
}
if (finished) {
object_partner.removeEventListener('click', () => issue_alert('partner_click'));
object_participant.removeEventListener('click', () => issue_alert('early_click_choice'));
const save_data = {
choice: choice,
selector: trial.selector,
together_side: trial.together_side,
response_time: response_time,
}
setTimeout(() => {
jsPsych.finishTrial(save_data);
}, 500);
}
}
async function show_alert(message) {
const dialog = document.createElement("dialog");
document.body.appendChild(dialog);
dialog.className = "z-20 fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-4 bg-slate-100 text-red-700 rounded-lg shadow-lg";
dialog.innerText = message;
dialog.show();
setTimeout(function () {
dialog.close();
}, 2000);
}
function issue_alert(issue){
if (issue === 'partner_click'){
show_alert(`That is your partner's object. Please click the lower object.`)
}
if (issue === 'early_click_go' && !go_clicked){
show_alert(`You need to click 'Go' before clicking your object.`)
}
if (issue === 'early_click_choice'){
if (!partner_choice_made && trial.selector === 'partner'){
show_alert(`Please wait for your partner to make a choice.`)
}
if (!choice && trial.selector === 'participant'){
show_alert(`Please make a choice using the buttons below before clicking your object.`)
}
}
}
}
}
export default jsPsychObjectMoving;

View File

@@ -1,20 +1,3 @@
import getColourMap from './colours.js';
// Define image URLs
const blueImages = {
instructions_1: '/images/blue/instructions_1.png',
instructions_2: '/images/blue/instructions_2.png',
instructions_3: '/images/blue/instructions_3.png',
instructions_4: '/images/blue/instructions_4.png',
};
const redImages = {
instructions_1: '/images/red/instructions_1.png',
instructions_2: '/images/red/instructions_2.png',
instructions_3: '/images/red/instructions_3.png',
instructions_4: '/images/red/instructions_4.png',
};
import html from '../utils/html.js'; import html from '../utils/html.js';
export const textStimuli = { export const textStimuli = {
@@ -25,31 +8,31 @@ export const textStimuli = {
`, `,
}; };
function getStimulusMap(together_colour) { function getStimulusMap() {
const colourMap = getColourMap(together_colour);
const stimulusMap = new Map(); const stimulusMap = new Map();
// Select the appropriate image map based on the color stimulusMap.set(
const imageMap = together_colour === 'blue' ? blueImages : redImages; 'pre_survey_info',
html`
<p class="leading-relaxed>
stimulusMap.set('pre_survey_info', html` The captcha task is now complete.
<p class="leading-relaxed"> </p>
You will now answer a few questions about your experience in the experiment. It is important that you read the questions carefully and answer them honestly. <p class="leading-relaxed mt-2"">
</p> You will now answer a few questions about your experience in the
<p class ="leading-relaxed mt-2"> experiment.
Nonsense or random answers may lead to your submission being rejected. </p>
</p> <p class="leading-relaxed mt-2">
<p class="mt-6"> It is important that you read the questions carefully and
Press answer them honestly.
<strong>SPACE</strong> </p>
to continue. <p class="mt-6">
</p> Press
` <strong>SPACE</strong>
to continue.
</p>
`
); );
stimulusMap.set( stimulusMap.set(
'pre_consent_info', 'pre_consent_info',
html` html`
@@ -126,15 +109,16 @@ function getStimulusMap(together_colour) {
</p> </p>
The controller within the meaning of the EU General Data Protection The controller within the meaning of the EU General Data Protection
Regulation (GDPR) and other national data protection laws of the member Regulation (GDPR) and other national data protection laws of the member
states, as well as other data protection regulations is the University of Muenster, represented by the Rector, Prof. Dr. states, as well as other data protection regulations is the University
Johannes Wessels, Schlossplatz 2, 48149 MünsterTel.: + 49 251 of Muenster, represented by the Rector, Prof. Dr. Johannes Wessels,
83-0E-Mail: verwaltung@uni-muenster.de Schlossplatz 2, 48149 MünsterTel.: + 49 251 83-0E-Mail:
verwaltung@uni-muenster.de
<p class="font-semibold mt-2"> <p class="font-semibold mt-2">
8. Contact details of the data protection officer 8. Contact details of the data protection officer
</p> </p>
The data protection officer of the University of Muenster is: Nina Meyer-Pachur The data protection officer of the University of Muenster is: Nina
Schlossplatz 2, 48149 Münster Tel.: + 49 251 83-22446 E-Mail: Meyer-Pachur Schlossplatz 2, 48149 Münster Tel.: + 49 251 83-22446
datenschutz@uni-muenster.de E-Mail: datenschutz@uni-muenster.de
<p class="font-semibold mt-2"> <p class="font-semibold mt-2">
9. Reference to the rights of those affected 9. Reference to the rights of those affected
</p> </p>
@@ -180,44 +164,18 @@ function getStimulusMap(together_colour) {
` `
); );
stimulusMap.set(
'multi_user_instructions',
html`
<p class="leading-relaxed">
In this experiment, you will connect and work with a partner.
</p>
<p class="leading-relaxed">
It is
<i>critical</i>
that you only continue if you are able to complete the entire experiment
without interruption and with a stable internet connection.
</p>
<p class="leading-relaxed mt-6">
Press
<strong>SPACE</strong>
once you are ready to be paired with a partner.
</p>
`
);
stimulusMap.set( stimulusMap.set(
'instructions_1', 'instructions_1',
html` html`
<p class="leading-relaxed"> <p>In this experiment, you will be solving captchas.</p>
In this study, you will work with your partner to complete a task <p class="mt-2">
involving moving objects to one of two goals. This will involve identifying slightly distorted letters and numbers
from an image.
</p> </p>
<p class="leading-relaxed mt-2"> <p class="mt-6">
In each round, one of you will be assigned the role of the Your task is to solve the captchas as quickly and accurately as
<strong>Selector.</strong> possible. </p>
The Selector will choose to which goal the object(s) will be moved. <p class="mt-6">
</p>
<img
class="w-6/12 mx-auto my-8 border-3 border-gray-500 rounded-md"
src="${imageMap.instructions_1}"
alt="instructions_1"
/>
<p class="leading-relaxed mt-6">
Press Press
<strong>SPACE</strong> <strong>SPACE</strong>
to continue. to continue.
@@ -227,119 +185,34 @@ function getStimulusMap(together_colour) {
stimulusMap.set( stimulusMap.set(
'instructions_2', 'instructions_2',
// prettier-ignore
html` html`
<p class="leading-relaxed"> <p>
One of the goals (labelled In this study, we will randomise the difficulty of the captchas you will solve. </p>
<span class="font-semibold ${colourMap.get('together')}"> <p class="mt-2">
Together</span>) You will roll a virtual die to determine the difficulty of the captchas you will see.
will require both of you to move an object each. The other goal </p>
(labelled <p class="mt-2">
<span class="font-semibold ${colourMap.get('alone')}"> The higher the number you roll, the more difficult the captchas will be.
Alone</span>) </p>
will <p class="mt-6">
<i>only</i> Press
require the Selector to move an object. <strong>SPACE</strong>
</p> to continue.
<p class="leading-relaxed mt-2"> </p>
When you are the Selector, you will see two buttons at the bottom of your `
screen. The buttons colours correspond to the colour of the goal location
(e.g., red button for the red goal).
</p>
<img
class="w-6/12 mx-auto my-8 border-3 border-gray-500 rounded-md"
src="${imageMap.instructions_2}"
alt="instructions_2"
/>
<p class="leading-relaxed mt-6">
Press
<strong>SPACE</strong>
to continue.
</p>
`
); );
stimulusMap.set( stimulusMap.set(
'instructions_3', 'instructions_3',
html` html`
<p class="leading-relaxed"> <p>
If you are not the Selector, you will need to wait until your partner You will now proceed to the captcha task </p> <p class = "mt-2"> You will need to
makes a decision. solve 12 captchas in a row as quickly and accurately as possible.
</p> </p>
<p class="leading-relaxed mt-2"> <p class="mt-6 text-red-500">
Once a decision is made, you will first need to click the
<span class="${colourMap.get('go')} font-semibold">Go</span>
button to make your object movable. Then, once you click an object, it
will teleport to the goal location.
</p>
<img
class="w-6/12 mx-auto my-8 border-3 border-gray-500 rounded-md"
src="${imageMap.instructions_3}"
alt="instructions_3"
/>
<p class="leading-relaxed mt-6">
Press Press
<strong>SPACE</strong> <strong>SPACE</strong>
to continue. when you are ready to begin.
</p>
`
);
stimulusMap.set(
'instructions_4',
html`
<p class="leading-relaxed">
If the Selector chooses the
<span class="font-semibold ${colourMap.get('alone')}">Alone</span>
goal, one object will disappear, and the Selector will move the
remaining object.
</p>
<p class="leading-relaxed mt-2">
Otherwise, if the Selector chooses
<span class="font-semibold ${colourMap.get('together')}">
Together
</span>
, each of you will need to click an object - only click the object that
is lower on your screen. The other is for your partner.
</p>
<img
class="w-6/12 mx-auto my-8 border-3 border-gray-500 rounded-md"
src="${imageMap.instructions_4}"
alt="instructions_4"
/>
<p class="leading-relaxed mt-6">
Press
<strong>SPACE</strong>
to continue.
</p>
`
);
stimulusMap.set(
'pre_practice_instructions',
html`
<p class="leading-relaxed">
You will now complete a short practice task with your partner.
</p>
<p class="leading-relaxed mt-6">
Press
<strong>SPACE</strong>
to continue.
</p>
`
);
stimulusMap.set(
'pre_task_instructions',
html`
<p class="leading-relaxed">
Practice complete. You will now complete the main task. This will take
around five minutes.
</p>
<p class="leading-relaxed">
Press
<strong>SPACE</strong>
to continue.
</p> </p>
` `
); );
@@ -360,10 +233,13 @@ function getStimulusMap(together_colour) {
</p> </p>
<p class="mt-2"> <p class="mt-2">
To this end, we created the appearance of additional participants in To this end, we created the appearance of additional participants in
the experiment in reality, you performed the task alone with your 'partner's' actions being peformed by a computer. the experiment in reality, you performed the task alone with your
'partner's' actions being peformed by a computer.
</p> </p>
<p class="mt-2"> <p class="mt-2">
You may have also read in the post-experiment questionnaire that your partner was either an AI agent this was to examine whether peoples suspicions are biased by the way in which they are asked about them. You may have also read in the post-experiment questionnaire that your
partner was either an AI agent this was to examine whether peoples
suspicions are biased by the way in which they are asked about them.
</p> </p>
<p class="mt-2"> <p class="mt-2">
Should you have any additional questions, you may contact Dr Shaheed Should you have any additional questions, you may contact Dr Shaheed

View File

@@ -29,4 +29,131 @@
.object-moving-box { .object-moving-box {
width: 3vw; width: 3vw;
height: 3vw; height: 3vw;
} }
.jspsych_die_roll {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 1.25rem;
}
.jspsych_die_roll_prompt {
max-width: 40rem;
}
.jspsych_die_roll_face {
width: 7.5rem;
height: 7.5rem;
border-radius: 1rem;
border: 4px solid #0f172a;
display: flex;
align-items: center;
justify-content: center;
font-size: 3.5rem;
font-weight: 700;
background: linear-gradient(145deg, #f1f5f9, #e2e8f0);
box-shadow: 0 20px 35px rgba(15, 23, 42, 0.2);
cursor: pointer;
user-select: none;
transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
}
.jspsych_die_roll_result {
font-weight: 600;
min-height: 1.5rem;
}
.jspsych_die_roll_face--interactive:hover {
transform: scale(1.03);
box-shadow: 0 24px 40px rgba(15, 23, 42, 0.25);
}
.jspsych_die_roll_face--active {
animation: pulse 0.6s ease-in-out infinite alternate;
}
.jspsych_die_roll_face--locked {
cursor: not-allowed;
opacity: 0.5;
box-shadow: 0 15px 25px rgba(15, 23, 42, 0.15);
background: linear-gradient(145deg, #d4d8de, #cbd2db);
color: #475569;
}
@keyframes pulse {
from {
transform: scale(1);
}
to {
transform: scale(1.05);
}
}
.jspsych_captcha {
max-width: 34rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
text-align: center;
}
.jspsych_captcha_canvas_wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
}
.jspsych_captcha_canvas {
border-radius: 0.75rem;
border: 2px solid #cbd5f5;
background: #fff;
}
.jspsych_captcha_refresh {
border: none;
background: #e2e8f0;
border-radius: 9999px;
width: 2.75rem;
height: 2.75rem;
font-size: 1.35rem;
cursor: pointer;
}
.jspsych_captcha_label {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-weight: 600;
align-items: center;
width: 100%;
}
.jspsych_captcha_input {
border: 2px solid #cbd5f5;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-size: 1.25rem;
letter-spacing: 0.2rem;
text-transform: uppercase;
text-align: center;
max-width: 16rem;
}
.jspsych_captcha_length_hint {
font-size: 0.9rem;
font-weight: 500;
color: #475569;
min-height: 1.25rem;
}
.jspsych_captcha_error {
min-height: 1.25rem;
color: #b91c1c;
font-weight: 600;
}