From e33983dcfb2edfbd4b85fdc0db047347259d1290 Mon Sep 17 00:00:00 2001
From: Hannah Tsukamoto
Date: Wed, 16 Sep 2026 19:03:40 -0400
Subject: [PATCH 01/11] docs(cwg): record JATOS adapter validation
---
JATOS-ADAPTER-VALIDATION.md | 70 +++++++++++++++++++++++++++++++++++++
README.md | 12 +++++--
2 files changed, 79 insertions(+), 3 deletions(-)
create mode 100644 JATOS-ADAPTER-VALIDATION.md
diff --git a/JATOS-ADAPTER-VALIDATION.md b/JATOS-ADAPTER-VALIDATION.md
new file mode 100644
index 0000000..660e3ef
--- /dev/null
+++ b/JATOS-ADAPTER-VALIDATION.md
@@ -0,0 +1,70 @@
+# JATOS adapter validation record
+
+Status: **validated at the adapter boundary; C&WG integration not yet validated.**
+
+This is the repository-side record of the live validation of
+[`jspsych/jspsych-multiplayer` PR #88](https://github.com/jspsych/jspsych-multiplayer/pull/88).
+It is deliberately more than a link to the upstream PR: C&WG relies on the observed lifecycle
+ordering below, so the evidence and its limits need to travel with the study integration work.
+
+## Scope and evidence
+
+The probe was a direct adapter-only JATOS group study. It did **not** load jsPsych core,
+multiplayer plugins, or either reference-game build. Therefore it establishes the adapter's
+group/presence behavior, but not compatibility with the C&WG timeline.
+
+The live probe observed:
+
+- A and B received the same JATOS group ID.
+- Every presence-event snapshot matched an independent `getPresence()` read.
+- Either participant could seal the group.
+- Once sealed, A's departure did not allow C to replace A in B's group; C was placed in a new
+ group instead.
+- Explicit disconnect completed in order: local disconnect, then left group, then promise
+ resolution.
+- On a member departure, the remaining peer received `member-leave` before `member-close`.
+ In that interval the leaver was absent from assigned membership but still appeared in open
+ channels.
+
+The upstream PR documents this ordering and has 44 passing adapter tests at the time of this
+record. The live probe and those upstream tests are the evidence for the claims above; a passing
+C&WG structural test must not be represented as equivalent evidence.
+
+## C&WG integration contract
+
+The C&WG integration must:
+
+1. derive both `dyad_id` and the deterministic schedule seed from the shared JATOS group ID;
+2. wait for two *assigned live members*, then seal successfully before role assignment;
+3. treat membership and leave events as authoritative for admission and departure decisions;
+4. never use open-channel count alone to infer that a departed member remains part of the dyad;
+5. retain the existing timeout-plus-silence rule only as a fallback while full-study disconnect
+ behavior is being verified; and
+6. send every pre-task failure through one existing no-match terminal route.
+
+## Explicit dependency and merge gate
+
+The C&WG branch depends on the exact `jspsych-multiplayer` PR #88 branch commit used to build the
+JATOS adapter browser artifact. Record that full SHA and artifact provenance in the integration
+commit that first loads the adapter. Do not replace this with an unpinned branch URL, and do not
+claim launch readiness while the dependency is unpublished.
+
+Before merging or piloting the C&WG integration, run the full study against that pinned artifact in
+a deployed JATOS group study and demonstrate:
+
+- shared group-derived seed and dyad ID for both partners;
+- seal-before-role behavior for a normal pair;
+- no-match routing when a peer leaves before sealing;
+- no replacement in a sealed group after a task peer leaves; and
+- exactly one terminal screen and one cleanup path per participant.
+
+## Still owned upstream
+
+These adapter hardening cases remain in PR #88 and are not evidence supplied by the probe above:
+
+- transient reconnect;
+- pending-join versus lobby-timeout race; and
+- failed leave.
+
+Until their behavior is documented and tested upstream, C&WG must preserve its bounded lobby and
+defensive terminal routing rather than assuming an adapter operation is infallible.
diff --git a/README.md b/README.md
index e34b6bd..5d022ad 100644
--- a/README.md
+++ b/README.md
@@ -48,9 +48,15 @@ These currently run on `adapter-multiplayer-local` (no backend needed) for two-t
into a second tab so a second player joins.
4. The first tab becomes the director, the second the matcher.
-For a paid Prolific run, swap `adapter-multiplayer-local` for
-[`adapter-multiplayer-firebase`](https://github.com/jspsych/jspsych-multiplayer/tree/main/packages/adapter-multiplayer-firebase)
-(one script-tag swap, see the header comment in each file) plus a real waiting room for pairing.
+The local adapter is only for two-tab piloting. The proposed paid-run path for C&WG is now JATOS
+group studies, using the JATOS adapter work in
+[`jspsych-multiplayer` PR #88](https://github.com/jspsych/jspsych-multiplayer/pull/88). It is not a
+script-tag swap: the study must derive its shared schedule seed and dyad ID from the JATOS group,
+admit two live members, seal that group before role assignment, and retain bounded no-match and
+dropout exits. The adapter dependency remains unpublished and must be pinned to an exact upstream
+commit until it is merged and released. See
+[`JATOS-ADAPTER-VALIDATION.md`](JATOS-ADAPTER-VALIDATION.md) for the live adapter evidence,
+observed leave-event ordering, and the C&WG integration gates.
**Note on package versions:** the `@jspsych-multiplayer/*` package script tags below are pinned to
`0.1.0` on jsDelivr, but those packages are not yet published to npm — publishing is gated on
From e1a3b2bc76999a9e44e2d9bc018711153e5c2d22 Mon Sep 17 00:00:00 2001
From: Hannah Tsukamoto
Date: Wed, 16 Sep 2026 19:19:33 -0400
Subject: [PATCH 02/11] docs(cwg): pin JATOS adapter dependency
---
JATOS-ADAPTER-VALIDATION.md | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/JATOS-ADAPTER-VALIDATION.md b/JATOS-ADAPTER-VALIDATION.md
index 660e3ef..ff2d281 100644
--- a/JATOS-ADAPTER-VALIDATION.md
+++ b/JATOS-ADAPTER-VALIDATION.md
@@ -44,10 +44,22 @@ The C&WG integration must:
## Explicit dependency and merge gate
-The C&WG branch depends on the exact `jspsych-multiplayer` PR #88 branch commit used to build the
-JATOS adapter browser artifact. Record that full SHA and artifact provenance in the integration
-commit that first loads the adapter. Do not replace this with an unpinned branch URL, and do not
-claim launch readiness while the dependency is unpublished.
+The C&WG branch is pinned to `jspsych/jspsych-multiplayer` PR #88 commit
+[`466fe5f486e2cbff4259b41e14d2fd7f63c5ea9a`](https://github.com/jspsych/jspsych-multiplayer/commit/466fe5f486e2cbff4259b41e14d2fd7f63c5ea9a).
+The adapter's IIFE build exposes `jsPsychAdapterMultiplayerJatos` and its source defines the
+`groupId`, `getPresence()`, `subscribePresence()`, and `sealGroup()` APIs used by this plan.
+
+PR #88 does not commit its generated `dist/` files. When the study first loads this adapter, rebuild
+`packages/adapter-multiplayer-jatos/dist/index.browser.min.js` from that exact commit and vendor the
+result with provenance, rather than linking an unpinned branch URL or hand-editing a bundle.
+
+The published npm package `@jspsych-multiplayer/adapter-multiplayer-jatos@0.1.0` is **not** an
+interchangeable substitute. Its source commit is
+`6e13f54805a2832e9a0969cf4898cd48ac6658d3`; inspection of its browser bundle found the base
+adapter contract only, without the PR #88 presence and group-sealing APIs. Do not use it for this
+integration.
+
+Do not claim launch readiness while the PR #88 dependency is unpublished.
Before merging or piloting the C&WG integration, run the full study against that pinned artifact in
a deployed JATOS group study and demonstrate:
From 3e635e1e8efe72f6ac3e353174c9a36b5460f0b0 Mon Sep 17 00:00:00 2001
From: Hannah Tsukamoto
Date: Wed, 16 Sep 2026 19:31:32 -0400
Subject: [PATCH 03/11] chore(cwg): vendor pinned JATOS adapter
---
vendor/README.md | 13 +++++++++++--
vendor/adapter-multiplayer-jatos.js | 2 ++
2 files changed, 13 insertions(+), 2 deletions(-)
create mode 100644 vendor/adapter-multiplayer-jatos.js
diff --git a/vendor/README.md b/vendor/README.md
index 0839d50..b76b2ee 100644
--- a/vendor/README.md
+++ b/vendor/README.md
@@ -21,13 +21,22 @@ the multiplayer API to the `jsPsych.multiplayer` namespace. The published `0.1.0
bundles predate that migration and still call the old core API, so mixing them
with the pinned core does not work.
-Vendoring all four keeps the plugin bundles and the pinned core consistent.
+Vendoring the four original bundles keeps the plugin bundles and the pinned core consistent.
+
+`adapter-multiplayer-jatos.js` is a separate, C&WG-only integration dependency. It was rebuilt
+from `jspsych/jspsych-multiplayer` PR #88 commit
+[`466fe5f486e2cbff4259b41e14d2fd7f63c5ea9a`](https://github.com/jspsych/jspsych-multiplayer/commit/466fe5f486e2cbff4259b41e14d2fd7f63c5ea9a),
+using that package's `npm ci --include=dev` and
+`npm run build --workspace=@jspsych-multiplayer/adapter-multiplayer-jatos`. The vendored file is
+`packages/adapter-multiplayer-jatos/dist/index.browser.min.js` (SHA-256
+`aed46860875bc91020fd244501429568cce80f275af86980c6bc6833bd0648ba`). It deliberately does not
+use the published `0.1.0` npm bundle, which predates PR #88's presence and group-sealing APIs.
## Provenance
Built with `npm run build` from `jspsych/jspsych-multiplayer` at commit
[`69c0d7b`](https://github.com/jspsych/jspsych-multiplayer/commit/69c0d7b).
-Each file is that package's `dist/index.browser.min.js`, renamed.
+Each original file is that package's `dist/index.browser.min.js`, renamed.
The experiment files load these over jsDelivr, SHA-pinned to the commit that added
them (`5046bf0`). If you rebuild and recommit the bundles, you must also bump that
diff --git a/vendor/adapter-multiplayer-jatos.js b/vendor/adapter-multiplayer-jatos.js
new file mode 100644
index 0000000..ea6e64e
--- /dev/null
+++ b/vendor/adapter-multiplayer-jatos.js
@@ -0,0 +1,2 @@
+var jsPsychAdapterMultiplayerJatos=function(){"use strict";var m=Object.defineProperty,b=Object.defineProperties,P=Object.getOwnPropertyDescriptors,d=Object.getOwnPropertySymbols,g=Object.prototype.hasOwnProperty,A=Object.prototype.propertyIsEnumerable,p=(l,e,n)=>e in l?m(l,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):l[e]=n,f=(l,e)=>{for(var n in e||(e={}))g.call(e,n)&&p(l,n,e[n]);if(d)for(var n of d(e))A.call(e,n)&&p(l,n,e[n]);return l},w=(l,e)=>b(l,P(e)),j=(l,e,n)=>new Promise((s,o)=>{var r=t=>{try{i(n.next(t))}catch(a){o(a)}},c=t=>{try{i(n.throw(t))}catch(a){o(a)}},i=t=>t.done?s(t.value):Promise.resolve(t.value).then(r,c);i((n=n.apply(l,e)).next())});class v{constructor(e={}){this.connected=!1,this.channelClosed=!1,this.subscribers=new Set,this.connectPromise=null,this.cancelPendingConnect=null,this.finishDisconnectAfterConnect=null,this.disconnectPromise=null,this.cachedGroupId=null,this.connectionGeneration=0,this.presenceSubscribers=new Set,this.sealPromise=null;var n,s;if(typeof jatos=="undefined")throw new Error("JatosAdapter: the jatos global is not defined. Ensure jatos.js is loaded before creating a JatosAdapter. This adapter only works when the experiment is running inside JATOS.");this.participantId=String((n=jatos.studyResultId)!=null?n:jatos.workerId),this.connectTimeoutMs=(s=e.connectTimeoutMs)!=null?s:2e4}get groupId(){return this.cachedGroupId}getPresence(){var e,n;const s=Object.freeze(Array.from((e=jatos.groupMembers)!=null?e:[],r=>String(r))),o=Object.freeze(Array.from((n=jatos.groupChannels)!=null?n:[],r=>String(r)).filter(r=>this.connected||r!==this.participantId));return Object.freeze({groupId:this.groupId,assignedMemberIds:s,openChannelMemberIds:o,localChannelOpen:this.connected})}subscribePresence(e){return this.presenceSubscribers.add(e),this.notifyPresenceSubscriber(e,{type:"snapshot",snapshot:this.getPresence()}),()=>this.presenceSubscribers.delete(e)}sealGroup(){if(!this.connected)return Promise.reject(new Error("JatosAdapter: sealGroup() requires an open group channel."));if(typeof jatos.setGroupFixed!="function")return Promise.reject(new Error("JatosAdapter: this jatos.js version does not expose setGroupFixed()."));if(this.sealPromise!==null)return this.sealPromise;const e=this.connectionGeneration;this.sealPromise=new Promise((s,o)=>{jatos.setGroupFixed(()=>{e===this.connectionGeneration&&this.emitPresence("group-fixed"),s()},r=>{const c=new Error("JatosAdapter: failed to fix the JATOS group.");c.cause=r,o(c)})});const n=this.sealPromise;return n.catch(()=>{this.sealPromise===n&&(this.sealPromise=null)}),n}connect(){if(this.finishDisconnectAfterConnect!==null)return Promise.reject(new Error("JatosAdapter: cannot connect while a cancelled JATOS join is still settling."));if(this.connectPromise!==null)return this.connectPromise;const e=++this.connectionGeneration;this.connectPromise=new Promise((s,o)=>{let r=!1,c=null;const i=setTimeout(()=>{r||e!==this.connectionGeneration||(r=!0,this.connectionGeneration++,this.cancelPendingConnect=null,this.emitPresence("local-error",void 0,"connection timed out"),o(new Error(`JatosAdapter: timed out after ${this.connectTimeoutMs} ms waiting for the group channel to open. JATOS reported neither success nor failure \u2014 the server may be unreachable or the handshake was dropped.`)))},this.connectTimeoutMs);this.cancelPendingConnect=()=>{r||(r=!0,clearTimeout(i),o(new Error("JatosAdapter: connect() was cancelled by disconnect().")))},jatos.joinGroup({onOpen:()=>{if(e!==this.connectionGeneration)return;const t=jatos.groupResultId==null?null:String(jatos.groupResultId);if(this.finishDisconnectAfterConnect!==null){t!==null&&(this.cachedGroupId=t),this.finishDisconnectAfterConnect(!0);return}if(t===null||c!==null&&t!==c){const a=new Error(t===null?"JatosAdapter: the group channel opened without a JATOS groupResultId.":`JatosAdapter: reopened channel changed group ID from ${c} to ${t}.`);this.connected=!1,this.channelClosed=!0,this.connectionGeneration++,this.cancelPendingConnect=null,this.emitPresence("local-error",void 0,a),r||(r=!0,clearTimeout(i),o(a));return}c=t,this.cachedGroupId=t,this.connected=!0,this.channelClosed=!1,this.emitPresence("local-open"),!r&&(r=!0,clearTimeout(i),this.cancelPendingConnect=null,s())},onGroupSession:()=>{if(e!==this.connectionGeneration||this.finishDisconnectAfterConnect!==null)return;const t=this.getAll();for(const a of this.subscribers)try{a(t)}catch(h){console.error("JatosAdapter: a group-session subscriber threw",h)}},onError:t=>{if(e===this.connectionGeneration){if(this.finishDisconnectAfterConnect!==null){this.finishDisconnectAfterConnect(!1);return}if(r){this.connected=!1,this.channelClosed=!0,this.emitPresence("local-error",void 0,t);return}r=!0,clearTimeout(i),this.cancelPendingConnect=null,this.channelClosed=!0,this.connectionGeneration++,this.emitPresence("local-error",void 0,t),o(new Error(`JatosAdapter: failed to join group \u2014 ${t!=null?t:"unknown error"}`))}},onClose:()=>{if(e===this.connectionGeneration){if(this.finishDisconnectAfterConnect!==null){this.finishDisconnectAfterConnect(!1);return}this.connected=!1,this.channelClosed=!0,this.emitPresence("local-close")}},onMemberJoin:t=>{e===this.connectionGeneration&&this.finishDisconnectAfterConnect===null&&this.emitPresence("member-join",t)},onMemberOpen:t=>{e===this.connectionGeneration&&this.finishDisconnectAfterConnect===null&&this.emitPresence("member-open",t)},onMemberClose:t=>{e===this.connectionGeneration&&this.finishDisconnectAfterConnect===null&&this.emitPresence("member-close",t)},onMemberLeave:t=>{e===this.connectionGeneration&&this.finishDisconnectAfterConnect===null&&this.emitPresence("member-leave",t)}})});const n=this.connectPromise;return n.catch(()=>{this.connectPromise===n&&(this.connectPromise=null)}),n}push(e){return j(this,null,function*(){if(!this.connected)throw new Error(this.channelClosed?"JatosAdapter: push() called after the group channel closed.":"JatosAdapter: push() called before connect(); call connect() first.");const n=8;let s;for(let o=0;osetTimeout(i,c))}}})}getAll(){var e;return(e=jatos.groupSession.getAll())!=null?e:{}}get(e){return jatos.groupSession.get(e)}subscribe(e){return this.subscribers.add(e),()=>{this.subscribers.delete(e)}}disconnect(){var e;if(this.disconnectPromise!==null)return this.disconnectPromise;const n=this.cancelPendingConnect!==null;(e=this.cancelPendingConnect)==null||e.call(this),this.cancelPendingConnect=null,this.subscribers.clear(),this.connected=!1,this.channelClosed=!0,this.sealPromise=null,this.emitPresence("local-disconnect"),this.connectPromise=null;let s;const o=new Promise(h=>{s=h});this.disconnectPromise=o;let r=!1,c=null;const i=(h,u)=>{r||(r=!0,this.emitPresence(h,void 0,u),this.presenceSubscribers.clear(),s())},t=(h,u)=>{c!==null&&clearTimeout(c),this.connectionGeneration++,this.finishDisconnectAfterConnect=null,this.disconnectPromise=null,i(h,u)},a=()=>{if(typeof jatos.leaveGroup!="function"){t("left-group");return}jatos.leaveGroup(()=>t("left-group"),h=>t("leave-failed",h))};return n?(this.finishDisconnectAfterConnect=h=>{h?a():t("left-group")},c=setTimeout(()=>{i("leave-failed",new Error(`JatosAdapter: timed out after ${this.connectTimeoutMs} ms waiting for the cancelled JATOS join to settle. Late callbacks will still be handled to prevent a ghost member.`))},this.connectTimeoutMs)):(this.connectionGeneration++,a()),o}emitPresence(e,n,s){const o=Object.freeze(w(f(f({type:e},n===void 0?{}:{memberId:String(n)}),s===void 0?{}:{error:s}),{snapshot:this.getPresence()}));for(const r of this.presenceSubscribers)this.notifyPresenceSubscriber(r,o)}notifyPresenceSubscriber(e,n){try{e(n)}catch(s){console.error("JatosAdapter: a presence subscriber threw",s)}}}return v}();
+//# sourceMappingURL=index.browser.min.js.map
From b983aa22fbab04b42c154a95aa06deca76a1c89b Mon Sep 17 00:00:00 2001
From: Hannah Tsukamoto
Date: Wed, 16 Sep 2026 19:35:51 -0400
Subject: [PATCH 04/11] feat(cwg): integrate JATOS sealed dyad lobby
---
reference-game-cwg.html | 122 +++++++++++++++++++++++++++-------------
tests/exits.test.mjs | 6 +-
tests/pipeline.test.mjs | 4 +-
3 files changed, 87 insertions(+), 45 deletions(-)
diff --git a/reference-game-cwg.html b/reference-game-cwg.html
index 432aef0..5102075 100644
--- a/reference-game-cwg.html
+++ b/reference-game-cwg.html
@@ -18,7 +18,8 @@
published 0.1.0 builds predate the jsPsych.multiplayer namespace migration. Repoint to
cdn.jsdelivr.net/npm/... and delete vendor/ once jspsych-multiplayer PR #35 publishes.
See vendor/README.md. -->
-
+
+
@@ -110,6 +111,7 @@
// this is locked at publish. ~5 min is a starting point, to be refined from observed arrival
// rate in the pilot (#12) BEFORE the study is published.
LOBBY_TIMEOUT_MS: 300000,
+ JATOS_CONNECT_TIMEOUT_MS: 20000,
// B2 — what an unmatched participant is paid. Full task rate for the wait, NOT Prolific's
// $0.14/min floor: a floor-rate payment loses to returning the study and taking a short
@@ -176,7 +178,9 @@
};
const jsPsych = initJsPsych();
- const localAdapter = new jsPsychAdapterMultiplayerLocal({ persistParticipant: true });
+ const jatosAdapter = new jsPsychAdapterMultiplayerJatos({
+ connectTimeoutMs: CONFIG.JATOS_CONNECT_TIMEOUT_MS,
+ });
const SHAPES = Tangrams.STIMULI; // 12 canonical tangrams
// Seed the schedule from the SHARED SESSION ID, so each dyad gets its own target orders while the
@@ -188,10 +192,11 @@
// second tab arrives with it already in the pasted link, so both compute the same schedule.
// Swapping to the Firebase adapter: seed from whatever identifies the dyad there (e.g. the room
// id) — the requirement is only that both partners derive the same value.
- const SEED = new URLSearchParams(location.search).get("mp_session") ?? "cwg-fullboard";
+ let SEED = null;
// 6 trials, each a fresh random order of all 12. Deterministic given the seed, so both tabs agree
// without exchanging anything.
- const SCHEDULE = Tangrams.fullBoardSchedule(TRIALS, SEED);
+ let SCHEDULE = [];
+ let admittedMemberIds = [];
// ===============================================================================================
// PROLIFIC IDENTIFIERS AND PROVENANCE (#4)
@@ -216,7 +221,7 @@
// whose rows claim not to belong to one — self-inconsistent data that still LOOKS reconcilable,
// which is worse than either failure alone. The fallback makes it consistent; the warning below
// makes it loud, because a constant dyad id shared across all dyads is still wrong.
- const DYAD_ID = prolificParams.get("mp_session") ?? "cwg-fullboard";
+ let DYAD_ID = null;
// Attach to EVERY row. Analysis here is entirely dyad-level, so a row that cannot be traced to a
// pair is not analysable; and a row that cannot be traced to a PROLIFIC_PID cannot be paid for.
@@ -226,7 +231,7 @@
session_id: SESSION_ID,
dyad_id: DYAD_ID,
// Own participant id, so the two sides of a dyad are distinguishable within it.
- participant_id: localAdapter.participantId ?? null,
+ participant_id: jatosAdapter.participantId ?? null,
// Run provenance — needed to interpret data collected while piloting at different values.
experiment_file: "reference-game-cwg.html",
config_trials: TRIALS,
@@ -375,7 +380,7 @@
// so a PID in a filename publishes a directory of participant identifiers — a different and
// broader exposure than the same value sitting inside a saved row. Reconciliation still works,
// because the payload carries the PID. See D3/D6 in DECISIONS.md, both open with IRB.
- const participantKey = () => localAdapter?.participantId ?? "anon";
+ const participantKey = () => jatosAdapter?.participantId ?? "anon";
const configured = () => !!CONFIG.DATAPIPE_EXPERIMENT_ID;
@@ -637,8 +642,9 @@
let lobbyTicker = null;
const lobbyTrial = {
- type: jsPsychMultiplayerSync,
- push_data: () => ({ name: myName, joinedAt: Date.now() }),
+ // Presence, not retained group-session records, admits a dyad. In particular, member-leave
+ // is authoritative even while the leaver still appears in open channels.
+ type: jsPsychHtmlKeyboardResponse,
// An unbounded wait is the whole bug: the plugin's `timeout` default is null, so `wait()` was
// called with no bound and an unmatched participant sat here forever — no timeout, no exit, no
// completion code, no way to be paid for time they had already given up.
@@ -653,11 +659,13 @@
)} for waiting. You do not need to do anything.
Testing locally? Open this page in another tab,
keeping the ?mp_session= in the URL.
`,
- wait_for: (group) =>
- Object.values(group).filter((entry) => entry && entry.name).length >= MIN_PLAYERS,
- // A countdown, because an indefinite-feeling wait is what makes people abandon. The plugin
- // renders `message` once and does not update it, so the ticker is driven from here.
+ choices: "NO_KEYS",
on_load: () => {
+ const root = document.querySelector(".jspsych-html-keyboard-response-stimulus");
+ if (root) root.innerHTML = `Waiting for a partner to join…
+ This study is played in pairs, so we need to match you with someone before we can start.
+ Time remaining: —
+ If we cannot find you a partner in time, we will still pay you ${money(CONFIG.NO_MATCH_PAYMENT_USD)}.
`;
// This deadline runs slightly AHEAD of the real one: the plugin calls on_load immediately
// after rendering `message`, but only arms its own timer at `wait()`, after `push_data` has
// round-tripped. On a slow connection the display therefore reaches zero first — and a
@@ -679,34 +687,47 @@
};
tick();
lobbyTicker = setInterval(tick, 1000);
- },
- on_finish: (data) => {
- clearInterval(lobbyTicker);
- lobbyTicker = null;
- if (data.timed_out) {
- // The sync trial RESOLVES on timeout rather than aborting, so without this flag the
- // timeline walks straight into roleTrial and waits on its own predicate — swapping an
- // unbounded lobby for an unbounded pairing screen.
- noMatch = true;
- noMatchReason = "lobby_timeout";
- }
+ let settled = false;
+ let unsubscribe = () => {};
+ const finish = (reason, ids = []) => {
+ if (settled) return;
+ settled = true;
+ clearInterval(lobbyTicker); lobbyTicker = null; unsubscribe();
+ if (reason !== "matched") { noMatch = true; noMatchReason = reason; jsPsych.finishTrial({ lobby_outcome: reason }); return; }
+ admittedMemberIds = ids;
+ jatosAdapter.sealGroup().then(() => {
+ const p = jatosAdapter.getPresence();
+ const stillLive = ids.length === MIN_PLAYERS && ids.every((id) =>
+ p.assignedMemberIds.includes(id) && p.openChannelMemberIds.includes(id));
+ if (!stillLive) { noMatch = true; noMatchReason = "partner_left_before_seal"; }
+ jsPsych.finishTrial({ lobby_outcome: noMatch ? noMatchReason : "matched", admitted_member_ids: ids });
+ }).catch(() => { noMatch = true; noMatchReason = "seal_failed"; jsPsych.finishTrial({ lobby_outcome: noMatchReason }); });
+ };
+ const timer = setTimeout(() => finish("lobby_timeout"), CONFIG.LOBBY_TIMEOUT_MS);
+ const observe = ({ snapshot }) => {
+ const ids = snapshot.assignedMemberIds;
+ const live = ids.length === MIN_PLAYERS && ids.every((id) => snapshot.openChannelMemberIds.includes(id));
+ if (live) { clearTimeout(timer); finish("matched", [...ids].sort()); }
+ };
+ unsubscribe = jatosAdapter.subscribePresence(observe);
+ if (settled) unsubscribe();
},
};
const roleTrial = {
- type: jsPsychMultiplayerRole,
- roles: ["director", "matcher"],
- strategy: "random",
- overflow_role: "spectator",
- ready: (group) =>
- Object.keys(group).length >= MIN_PLAYERS &&
- Object.values(group).every((entry) => entry && entry.joinedAt != null),
- save_group: true,
- message: "Pairing you with your partner…
",
- on_finish: () => {
- myRole = jsPsychMultiplayerRole.getMyRole();
- const byRole = jsPsychMultiplayerRole.participantsByRole();
- partnerId = myRole === "director" ? byRole.matcher?.[0] : byRole.director?.[0];
+ type: jsPsychHtmlKeyboardResponse,
+ stimulus: "Pairing you with your partner…
", choices: "NO_KEYS",
+ on_load: async () => {
+ try {
+ await jatosAdapter.push({ name: myName, joinedAt: Date.now(), cwg_dyad_id: DYAD_ID });
+ const p = jatosAdapter.getPresence();
+ const live = admittedMemberIds.length === MIN_PLAYERS && admittedMemberIds.every((id) => p.assignedMemberIds.includes(id) && p.openChannelMemberIds.includes(id));
+ if (live && admittedMemberIds.includes(jatosAdapter.participantId)) {
+ const director = admittedMemberIds[Math.abs([...DYAD_ID].reduce((n, c) => ((n * 31) + c.charCodeAt(0)) | 0, 0)) % MIN_PLAYERS];
+ myRole = jatosAdapter.participantId === director ? "director" : "matcher";
+ partnerId = admittedMemberIds.find((id) => id !== jatosAdapter.participantId);
+ }
+ } finally {
// Anything that is not a playable role routes to the paid exit. Tested by exclusion rather
// than by listing the failures, because the cost of missing one is a participant who reaches
// the end of the timeline with no completion code and cannot submit.
@@ -731,7 +752,9 @@
// numbered bursts, the other is ghost entries in the lobby (#10) — and are sized differently.
if (myRole !== "director" && myRole !== "matcher") {
noMatch = true;
- noMatchReason = myRole === "spectator" ? "spectator_overflow" : "pairing_timeout";
+ noMatchReason = "partner_left_before_task";
+ }
+ jsPsych.finishTrial({ role: myRole ?? null, partner_id: partnerId ?? null });
}
},
};
@@ -803,7 +826,7 @@
type: jsPsychMultiplayerReferenceGame,
stimuli: SHAPES,
columns: 6, // 2 rows of 6, as in the original
- role: () => jsPsychMultiplayerRole.getMyRole(),
+ role: () => myRole,
partner_id: () => partnerId,
round: jsPsych.timelineVariable("round"),
targets: jsPsych.timelineVariable("targets"), // all 12 ids, ordered → full-board match
@@ -964,7 +987,17 @@
!partnerDropped && (myRole === "director" || myRole === "matcher"),
};
- jsPsych.multiplayer.connect(localAdapter).then(() => {
+ jatos.onLoad(async () => {
+ try {
+ await jsPsych.multiplayer.connect(jatosAdapter);
+ DYAD_ID = jatosAdapter.groupId;
+ if (!DYAD_ID) throw new Error("JATOS connected without a group ID");
+ SEED = DYAD_ID;
+ SCHEDULE = Tangrams.fullBoardSchedule(TRIALS, SEED);
+ gameLoop.timeline_variables = SCHEDULE;
+ // This second stamp happens before the first trial; it replaces the construction-time null
+ // placeholders with the shared JATOS identity on every subsequently collected row.
+ jsPsych.data.addProperties({ dyad_id: DYAD_ID, participant_id: jatosAdapter.participantId, jatos_group_id: DYAD_ID });
// Preload FIRST, before pairing: a participant who is still fetching images while their
// partner waits in the lobby wastes the partner's time.
jsPsych.run([
@@ -981,6 +1014,15 @@
partnerDroppedScreen,
completeScreen,
]);
+ } catch (error) {
+ console.error("[jatos] could not initialize group study", error);
+ DYAD_ID = `unassigned-${jatosAdapter.participantId ?? "anon"}`;
+ SEED = DYAD_ID;
+ jsPsych.data.addProperties({ dyad_id: DYAD_ID, participant_id: jatosAdapter.participantId ?? null });
+ noMatch = true;
+ noMatchReason = "adapter_connect_error";
+ jsPsych.run([noMatchScreen]);
+ }
});