diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c17e36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Locally built/downloaded bundles for reference-game-cwg-local.html. Deliberately NOT committed — +# this repo has twice reverted vendoring prebuilt multiplayer bundles into git. Rebuild them with the +# commands in that file's header comment. +vendor/ diff --git a/reference-game-hawkins-local.html b/reference-game-hawkins-local.html new file mode 100644 index 0000000..a8dcd89 --- /dev/null +++ b/reference-game-hawkins-local.html @@ -0,0 +1,474 @@ + + + + Reference Game — Hawkins et al. 2020 replication (sequential) — local build + + + + + + + + + + + + + + + + + + diff --git a/reference-game-hawkins.html b/reference-game-hawkins.html index d3c055d..a6307e2 100644 --- a/reference-game-hawkins.html +++ b/reference-game-hawkins.html @@ -21,6 +21,83 @@ rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@151ab520542a8e48bcc4d5b21c74cdffae8b48c6/packages/jspsych/css/jspsych.css" /> + @@ -45,8 +122,26 @@ // matcher can't blind-guess to rush through — verified in hawkrobe/tangrams game.client.js, // where the click handler is gated behind `if (globalGame.messageSent)`. // - // Every parameter above was verified against the paper + hawkrobe/tangrams code. The one departure - // is not a design choice: `round_timeout` (60s) has no counterpart in the original, which ran + // Re-audited line by line against the original source (OSF osf.io/vzvmf → github component = + // hawkrobe/tangrams, `experiments/tangrams_sequential`, i.e. the "cued" version — the README + // there defines cued as "a sequential version that cues each target in a sequence of trials"). + // Confirmed identical: 2 players; fixed director/matcher; 6×2 grid of SQUARE cells; 72 rounds as + // 6 shuffled blocks of 12 (game.core.js `getRandomizedConditions`); layouts re-randomised every + // round, independently per player; target revealed to the director only; unrestricted two-way + // chat; per-round chat log; the matcher-click gate (`globalGame.messageSent`); 3000ms of + // feedback before the next round (game.server.js `setTimeout(..., 3000)`); a typing indicator + // (sharedUtils/clientBase.js `playerTyping`); and a persistent round counter + running score. + // + // Position disjointness and per-role feedback were gaps until the plugin gained + // `scramble_mode: "disjoint"` and role-keyed `feedback_content`; both are now used below, and + // the always-green feedback highlight is restored in CSS. ONE known difference remains: + // Gate semantics. The original sets `messageSent` in its `chatMessage` handler, which fires + // for ANY message including the matcher's own — so a matcher could unlock their own click by + // typing anything. The plugin counts only the PARTNER's message. Ours is the stricter (and, + // judging by the source comment, the intended) reading. + // + // The remaining departure is not a design choice: `round_timeout` (60s) has no counterpart in the + // original, which ran // untimed. It exists so a disconnected or absent partner cannot hang the trial forever. Rounds it // ends are recorded as `ended_by: "timeout"` with a null assignment — filter on that when // analysing, and raise it if piloting shows real trials running long. @@ -141,15 +236,115 @@ conditional_function: () => myRole === "spectator", }; + // ============================================================================================= + // "Partner is typing…" indicator. + // + // The reference-game plugin has no typing indicator, so this is bolted on from the experiment + // using the multiplayer API directly — no plugin fork. It reads/writes ONE extra key in this + // participant's session slot, `typing_at` (a timestamp, or null), and renders the partner's. + // + // Why it is safe to write into the same slot the plugin writes: + // `push` REPLACES the whole slot, so every writer must read-modify-write. JS is single + // threaded and each read+push below happens in ONE synchronous block, so the plugin's chat and + // submit pushes can never interleave between this read and this push — no lost message. The + // only writer of my slot is my own tab. + // + // Rationale for the two constants: the TTL must exceed the throttle, or the hint blinks off + // between a continuously-typing partner's pushes. + // + // The original has this feature (sharedUtils/clientBase.js `playerTyping`), and it matters with + // the click gate on: the matcher sits blocked until the director's first message, and this is + // what distinguishes "they're composing a description" from "they've gone away". + // ============================================================================================= + const TYPING_TTL = 2500; // hide the hint this long after the partner's last keystroke + const TYPING_THROTTLE = 800; // push at most one typing ping per this many ms + + // Torn down at the end of every round by `stopTypingIndicator`; the plugin rebuilds the whole + // display each trial, so the hint element and its listeners must be re-created per round too. + let typingTeardown = null; + + function startTypingIndicator() { + const api = jsPsych.multiplayer; + const me = api.participantId; + const chat = document.querySelector(".jspsych-multiplayer-reference-game-chat"); + const form = document.querySelector(".jspsych-multiplayer-reference-game-chat-form"); + const input = document.querySelector(".jspsych-multiplayer-reference-game-chat-input"); + if (!chat || !form || !input) return; // chat disabled — nothing to attach to + + const hint = document.createElement("div"); + hint.className = "typing-hint"; + hint.setAttribute("role", "status"); + hint.setAttribute("aria-live", "polite"); + hint.textContent = `${myRole === "director" ? "Matcher" : "Director"} is typing…`; + hint.hidden = true; + chat.insertBefore(hint, form); + + // Read-modify-write, synchronously — see the header note. + const mark = (ts) => { + const mine = api.get(me) ?? {}; + api.push({ ...mine, typing_at: ts }).catch(() => {}); + }; + + let lastPush = 0; + const onInput = () => { + if (input.value.trim() === "") { + lastPush = 0; + mark(null); // cleared the box — stop signalling immediately + return; + } + const now = Date.now(); + if (now - lastPush < TYPING_THROTTLE) return; + lastPush = now; + mark(now); + }; + // Runs AFTER the plugin's own submit handler (added first, in trial()), so by the time this + // reads the slot the sent message is already in it. + const onSend = () => { + lastPush = 0; + mark(null); + }; + + const render = () => { + const at = partnerId ? api.getAll()?.[partnerId]?.typing_at : null; + hint.hidden = !(typeof at === "number" && Date.now() - at < TYPING_TTL); + }; + + input.addEventListener("input", onInput); + form.addEventListener("submit", onSend); + const unsubscribe = api.subscribe(render); + // subscribe only fires on CHANGES; a partner who stops typing pushes nothing more, so the hint + // needs a clock of its own to expire. + const ticker = setInterval(render, 500); + render(); + + typingTeardown = () => { + input.removeEventListener("input", onInput); + form.removeEventListener("submit", onSend); + unsubscribe(); + clearInterval(ticker); + mark(null); // never leave a stale "typing" flag for the next round + }; + } + + function stopTypingIndicator() { + typingTeardown?.(); + typingTeardown = null; + } + const gameRound = { type: jsPsychMultiplayerReferenceGame, stimuli: SHAPES, columns: 6, // 6×2 grid, as in the original + cell_size: 130, // square cells, as in the original's 300px ones (see the