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]); + } }); diff --git a/tests/exits.test.mjs b/tests/exits.test.mjs index 049a455..0d5a5f9 100644 --- a/tests/exits.test.mjs +++ b/tests/exits.test.mjs @@ -115,15 +115,15 @@ const classify = (myRole) => { check("director is not routed to the no-match exit", classify("director").noMatch === false); check("matcher is not routed to the no-match exit", classify("matcher").noMatch === false); -check("spectator routes, as spectator_overflow", classify("spectator").noMatch === true && classify("spectator").noMatchReason === "spectator_overflow"); +check("a non-player role routes to the paid no-match exit", classify("spectator").noMatch === true && classify("spectator").noMatchReason === "partner_left_before_task"); // The regression. `getMyRole()` returns undefined after a role-plugin timeout; `null` is what the // data row carries. Both must route, so that nobody "fixes" this by comparing against one of them. for (const value of [undefined, null]) { const s = classify(value); check( - `REGRESSION: pairing timeout (myRole=${String(value)}) routes to a paid exit, not off the end`, - s.noMatch === true && s.noMatchReason === "pairing_timeout", + `REGRESSION: failed task admission (myRole=${String(value)}) routes to a paid exit, not off the end`, + s.noMatch === true && s.noMatchReason === "partner_left_before_task", `${s.noMatch} / ${s.noMatchReason}` ); // And end-to-end: the state the classifier produces must land on exactly one screen. diff --git a/tests/pipeline.test.mjs b/tests/pipeline.test.mjs index 6199f76..bc45290 100644 --- a/tests/pipeline.test.mjs +++ b/tests/pipeline.test.mjs @@ -55,7 +55,7 @@ const fetch = async (url, opts) => { function makePipeline(dyadId, prolificPid, participantId = "adapter-id") { return new Function( "jsPsych", "CONFIG", "DATAPIPE_ENDPOINT", "DYAD_ID", "SEED", "PROLIFIC_PID", - "localAdapter", "fetch", "console", + "jatosAdapter", "fetch", "console", pipelineSrc + " return Pipeline;" )( jsPsych, CONFIG, DATAPIPE_ENDPOINT, dyadId, dyadId ?? "seed-fallback", prolificPid, @@ -259,7 +259,7 @@ console.log("\n--- REGRESSION: saves that fail OUT OF ORDER must not strand rows rows = []; const PL = new Function( "jsPsych", "CONFIG", "DATAPIPE_ENDPOINT", "DYAD_ID", "SEED", "PROLIFIC_PID", - "localAdapter", "fetch", "console", + "jatosAdapter", "fetch", "console", pipelineSrc + " return Pipeline;" )(jsPsych, CONFIG, DATAPIPE_ENDPOINT, "dOrder", "dOrder", "pO", { participantId: "aO" }, slowFetch, { warn() {}, error() {} }); From 1cae4bf85dde2fa6cc20454ecc0405a8cae42e70 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Wed, 16 Sep 2026 19:59:34 -0400 Subject: [PATCH 05/11] fix(cwg): provide JATOS lobby stimulus --- reference-game-cwg.html | 1 + 1 file changed, 1 insertion(+) diff --git a/reference-game-cwg.html b/reference-game-cwg.html index 5102075..614b2d9 100644 --- a/reference-game-cwg.html +++ b/reference-game-cwg.html @@ -645,6 +645,7 @@ // 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, + stimulus: "

Preparing the pairing lobby…

", // 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. From 3715efbfa4bb4ddc160c9f562de47ff92b303b78 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Wed, 16 Sep 2026 20:10:03 -0400 Subject: [PATCH 06/11] build(cwg): package JATOS smoke test --- .gitignore | 1 + README.md | 7 +++++ scripts/build-jatos-cwg.mjs | 55 +++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 .gitignore create mode 100644 scripts/build-jatos-cwg.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/README.md b/README.md index 5d022ad..62e1f33 100644 --- a/README.md +++ b/README.md @@ -64,3 +64,10 @@ observed leave-event ordering, and the C&WG integration gates. Packages") merging. Until then, either build the packages from a local checkout of jspsych-multiplayer and swap in relative `dist/` paths, or wait for the publish and confirm the pinned version still matches. + +## JATOS smoke-test package + +Run `node scripts/build-jatos-cwg.mjs` to create `dist/reference-game-cwg-jatos.jzip` for import +into JATOS. It packages the C&WG page, tangram assets, and pinned JATOS adapter, and configures a +two-active-member group batch with no historical-member cap. It is a deployment smoke test, not a +paid-study package. diff --git a/scripts/build-jatos-cwg.mjs b/scripts/build-jatos-cwg.mjs new file mode 100644 index 0000000..8446cec --- /dev/null +++ b/scripts/build-jatos-cwg.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Build an importable JATOS group-study archive for the C&WG JATOS smoke test. +// Usage: node scripts/build-jatos-cwg.mjs +// Output: dist/reference-game-cwg-jatos.jzip + +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const studyDirName = "reference-game-cwg-jatos"; +const distDir = resolve(root, "dist"); +const studyDir = resolve(distDir, studyDirName); +const zipPath = resolve(distDir, `${studyDirName}.jzip`); + +rmSync(studyDir, { recursive: true, force: true }); +rmSync(zipPath, { force: true }); +mkdirSync(studyDir, { recursive: true }); + +const adapterUrl = + "https://cdn.jsdelivr.net/gh/jspsych/multiplayer-test-experiments@3e635e1e8efe72f6ac3e353174c9a36b5460f0b0/vendor/adapter-multiplayer-jatos.js"; +const html = readFileSync(resolve(root, "reference-game-cwg.html"), "utf8"); +if (!html.includes(adapterUrl)) throw new Error("C&WG's pinned JATOS adapter URL changed; update this builder."); +writeFileSync(resolve(studyDir, "index.html"), html.replace(adapterUrl, "adapter-multiplayer-jatos.js")); +cpSync(resolve(root, "tangrams.js"), resolve(studyDir, "tangrams.js")); +cpSync(resolve(root, "vendor/adapter-multiplayer-jatos.js"), resolve(studyDir, "adapter-multiplayer-jatos.js")); +cpSync(resolve(root, "assets/tangrams"), resolve(studyDir, "assets/tangrams"), { recursive: true }); + +const metadata = { + version: "3", + data: { + uuid: randomUUID(), title: "C&WG reference game — JATOS smoke test", + description: "Two-person sealed-group integration test; not a paid study.", + groupStudy: true, linearStudy: false, allowPreview: false, dirName: studyDirName, + comments: "Import for live JATOS integration validation only.", jsonData: null, endRedirectUrl: null, + studyEntryMsg: null, + componentList: [{ uuid: randomUUID(), title: "C&WG JATOS test", htmlFilePath: "index.html", reloadable: false, active: true, comments: "", jsonData: null }], + batchList: [{ uuid: randomUUID(), title: "Dyad lobby", active: true, maxActiveMembers: 2, maxTotalMembers: null, maxTotalWorkers: null, allowedWorkerTypes: ["Jatos", "GeneralSingle", "GeneralMultiple"], comments: "Seal after two live members; retain historical membership limit unset.", jsonData: null }], + }, +}; +const jasName = `${studyDirName}.jas`; +writeFileSync(resolve(distDir, jasName), JSON.stringify(metadata, null, 2)); + +const ps = [ + "Add-Type -AssemblyName System.IO.Compression.FileSystem", + `$z=[System.IO.Compression.ZipFile]::Open(${JSON.stringify(zipPath)},'Create')`, + "$c=[System.IO.Compression.CompressionLevel]::Optimal", + `[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($z,${JSON.stringify(resolve(distDir, jasName))},${JSON.stringify(jasName)},$c)`, + `Get-ChildItem -LiteralPath ${JSON.stringify(studyDir)} -Recurse -File | % { $n=${JSON.stringify(studyDirName)}+'/' + $_.FullName.Substring(${studyDir.length}).TrimStart('\\','/').Replace('\\','/'); [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($z,$_.FullName,$n,$c) }`, + "$z.Dispose()", +].join(";"); +execFileSync("powershell", ["-NoProfile", "-Command", ps], { stdio: "inherit" }); +console.log(`Built ${zipPath}`); From aa430ead690f9a88a1ae3caafef4d742349e099d Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Wed, 16 Sep 2026 22:00:56 -0400 Subject: [PATCH 07/11] fix(cwg): handshake JATOS dyad admission --- reference-game-cwg.html | 43 +++++++++++++++++++++++++++++++++-------- tests/jatos.test.mjs | 16 +++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 tests/jatos.test.mjs diff --git a/reference-game-cwg.html b/reference-game-cwg.html index 614b2d9..6fb7428 100644 --- a/reference-game-cwg.html +++ b/reference-game-cwg.html @@ -640,6 +640,9 @@ const money = (usd) => `$${usd.toFixed(2)}`; let lobbyTicker = null; + // Immutable per-admission handshake data. Presence decides WHO is eligible; this record only + // confirms that both of those currently live members reached the same transition point. + let admissionRecord = null; const lobbyTrial = { // Presence, not retained group-session records, admits a dyad. In particular, member-leave @@ -696,13 +699,35 @@ 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 memberKey = ids.join("|"); + admissionRecord = { group_id: DYAD_ID, member_key: memberKey, ready_at: Date.now() }; + const bothReady = () => { + const group = jatosAdapter.getAll(); + return ids.every((id) => { + const record = group[id]?.cwg_lobby_admission; + return record?.group_id === DYAD_ID && record?.member_key === memberKey; + }); + }; + const waitForBoth = () => new Promise((resolve, reject) => { + if (bothReady()) return resolve(); + const timeout = setTimeout(() => { stop(); reject(new Error("admission handshake timed out")); }, CONFIG.JATOS_CONNECT_TIMEOUT_MS); + const stop = jatosAdapter.subscribe(() => { if (bothReady()) { clearTimeout(timeout); stop(); resolve(); } }); + }); + jatosAdapter.push({ cwg_lobby_admission: admissionRecord }) + .then(waitForBoth) + .then(() => jatosAdapter.sealGroup()) + .then(() => { + const p = jatosAdapter.getPresence(); + const stillLive = 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((error) => { + console.error("[jatos] lobby admission failed", error); + noMatch = true; + noMatchReason = "admission_handshake_failed"; + jsPsych.finishTrial({ lobby_outcome: noMatchReason }); + }); }; const timer = setTimeout(() => finish("lobby_timeout"), CONFIG.LOBBY_TIMEOUT_MS); const observe = ({ snapshot }) => { @@ -720,7 +745,9 @@ 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 }); + // push() replaces this participant's group-session entry. Preserve the successful lobby + // handshake so a fast client cannot erase it before its peer finishes observing it. + await jatosAdapter.push({ name: myName, joinedAt: Date.now(), cwg_dyad_id: DYAD_ID, cwg_lobby_admission: admissionRecord }); 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)) { diff --git a/tests/jatos.test.mjs b/tests/jatos.test.mjs new file mode 100644 index 0000000..faf9766 --- /dev/null +++ b/tests/jatos.test.mjs @@ -0,0 +1,16 @@ +// Structural regressions for the C&WG JATOS admission boundary. +import fs from "fs"; + +const html = fs.readFileSync(new URL("../reference-game-cwg.html", import.meta.url), "utf8"); +const results = []; +const check = (name, ok) => results.push([ok ? "PASS" : "FAIL", name]); + +check("loads the pinned JATOS adapter", /adapter-multiplayer-jatos\.js/.test(html)); +check("derives dyad identity from groupId", /DYAD_ID = jatosAdapter\.groupId/.test(html)); +check("uses presence for candidate membership", /jatosAdapter\.subscribePresence\(observe\)/.test(html)); +check("requires matching records from both candidate ids", /ids\.every\(\(id\) => \{[\s\S]*cwg_lobby_admission/.test(html)); +check("publishes readiness before sealing", /jatosAdapter\.push\(\{ cwg_lobby_admission: admissionRecord \}\)[\s\S]*?\.then\(waitForBoth\)[\s\S]*?\.then\(\(\) => jatosAdapter\.sealGroup\(\)\)/.test(html)); +check("preserves admission record in role write", /cwg_lobby_admission: admissionRecord/.test(html)); + +for (const [status, name] of results) console.log(`${status} ${name}`); +process.exit(results.every(([status]) => status === "PASS") ? 0 : 1); From ae0d45d37e320b1f338dc88d12892c6aae90ac9b Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Wed, 16 Sep 2026 22:07:58 -0400 Subject: [PATCH 08/11] fix(cwg): admit JATOS pair by membership --- reference-game-cwg.html | 18 ++++++++++++------ tests/jatos.test.mjs | 1 + 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/reference-game-cwg.html b/reference-game-cwg.html index 6fb7428..45d4606 100644 --- a/reference-game-cwg.html +++ b/reference-game-cwg.html @@ -718,8 +718,11 @@ .then(() => jatosAdapter.sealGroup()) .then(() => { const p = jatosAdapter.getPresence(); - const stillLive = ids.every((id) => p.assignedMemberIds.includes(id) && p.openChannelMemberIds.includes(id)); - if (!stillLive) { noMatch = true; noMatchReason = "partner_left_before_seal"; } + // Membership/leave is authoritative here. The completed two-sided write handshake + // already proves both channels were usable; groupChannels can lag membership during + // JATOS lifecycle transitions and must not undo a valid admission. + const stillAssigned = ids.every((id) => p.assignedMemberIds.includes(id)); + if (!stillAssigned) { noMatch = true; noMatchReason = "partner_left_before_seal"; } jsPsych.finishTrial({ lobby_outcome: noMatch ? noMatchReason : "matched", admitted_member_ids: ids }); }) .catch((error) => { @@ -732,8 +735,11 @@ 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()); } + // Do not gate on openChannelMemberIds. A leave event / assigned membership is the + // authoritative transition; the handshake below proves that both current members can + // actually exchange group-session writes. + const assignedPair = ids.length === MIN_PLAYERS; + if (assignedPair) { clearTimeout(timer); finish("matched", [...ids].sort()); } }; unsubscribe = jatosAdapter.subscribePresence(observe); if (settled) unsubscribe(); @@ -749,8 +755,8 @@ // handshake so a fast client cannot erase it before its peer finishes observing it. await jatosAdapter.push({ name: myName, joinedAt: Date.now(), cwg_dyad_id: DYAD_ID, cwg_lobby_admission: admissionRecord }); 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 stillAssigned = admittedMemberIds.length === MIN_PLAYERS && admittedMemberIds.every((id) => p.assignedMemberIds.includes(id)); + if (stillAssigned && 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); diff --git a/tests/jatos.test.mjs b/tests/jatos.test.mjs index faf9766..35a057d 100644 --- a/tests/jatos.test.mjs +++ b/tests/jatos.test.mjs @@ -8,6 +8,7 @@ const check = (name, ok) => results.push([ok ? "PASS" : "FAIL", name]); check("loads the pinned JATOS adapter", /adapter-multiplayer-jatos\.js/.test(html)); check("derives dyad identity from groupId", /DYAD_ID = jatosAdapter\.groupId/.test(html)); check("uses presence for candidate membership", /jatosAdapter\.subscribePresence\(observe\)/.test(html)); +check("does not gate admission on open-channel count", !/const live = ids\.length === MIN_PLAYERS && ids\.every\(\(id\) => snapshot\.openChannelMemberIds/.test(html)); check("requires matching records from both candidate ids", /ids\.every\(\(id\) => \{[\s\S]*cwg_lobby_admission/.test(html)); check("publishes readiness before sealing", /jatosAdapter\.push\(\{ cwg_lobby_admission: admissionRecord \}\)[\s\S]*?\.then\(waitForBoth\)[\s\S]*?\.then\(\(\) => jatosAdapter\.sealGroup\(\)\)/.test(html)); check("preserves admission record in role write", /cwg_lobby_admission: admissionRecord/.test(html)); From 566081f607cd7a99bc4ae6cb976a438d7ba02dae Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Wed, 16 Sep 2026 22:11:57 -0400 Subject: [PATCH 09/11] fix(cwg): poll JATOS admission presence --- reference-game-cwg.html | 9 ++++++++- tests/jatos.test.mjs | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/reference-game-cwg.html b/reference-game-cwg.html index 45d4606..cc51572 100644 --- a/reference-game-cwg.html +++ b/reference-game-cwg.html @@ -693,10 +693,13 @@ lobbyTicker = setInterval(tick, 1000); let settled = false; let unsubscribe = () => {}; + let presencePoll = null; const finish = (reason, ids = []) => { if (settled) return; settled = true; - clearInterval(lobbyTicker); lobbyTicker = null; unsubscribe(); + clearInterval(lobbyTicker); lobbyTicker = null; + if (presencePoll !== null) clearInterval(presencePoll); + unsubscribe(); if (reason !== "matched") { noMatch = true; noMatchReason = reason; jsPsych.finishTrial({ lobby_outcome: reason }); return; } admittedMemberIds = ids; const memberKey = ids.join("|"); @@ -742,7 +745,11 @@ if (assignedPair) { clearTimeout(timer); finish("matched", [...ids].sort()); } }; unsubscribe = jatosAdapter.subscribePresence(observe); + // JATOS normally emits member callbacks, but a client can subscribe between its initial + // snapshot and a peer's completed join. Polling the adapter's immutable current snapshot + // closes that missed-callback window; membership still remains the admission authority. if (settled) unsubscribe(); + else presencePoll = setInterval(() => observe({ snapshot: jatosAdapter.getPresence() }), 250); }, }; diff --git a/tests/jatos.test.mjs b/tests/jatos.test.mjs index 35a057d..c01a52e 100644 --- a/tests/jatos.test.mjs +++ b/tests/jatos.test.mjs @@ -8,6 +8,7 @@ const check = (name, ok) => results.push([ok ? "PASS" : "FAIL", name]); check("loads the pinned JATOS adapter", /adapter-multiplayer-jatos\.js/.test(html)); check("derives dyad identity from groupId", /DYAD_ID = jatosAdapter\.groupId/.test(html)); check("uses presence for candidate membership", /jatosAdapter\.subscribePresence\(observe\)/.test(html)); +check("polls current presence to close a missed-event window", /setInterval\(\(\) => observe\(\{ snapshot: jatosAdapter\.getPresence\(\) \}\), 250\)/.test(html)); check("does not gate admission on open-channel count", !/const live = ids\.length === MIN_PLAYERS && ids\.every\(\(id\) => snapshot\.openChannelMemberIds/.test(html)); check("requires matching records from both candidate ids", /ids\.every\(\(id\) => \{[\s\S]*cwg_lobby_admission/.test(html)); check("publishes readiness before sealing", /jatosAdapter\.push\(\{ cwg_lobby_admission: admissionRecord \}\)[\s\S]*?\.then\(waitForBoth\)[\s\S]*?\.then\(\(\) => jatosAdapter\.sealGroup\(\)\)/.test(html)); From 6a08c5f1a8fc05db6451c65584c8dca2781265d5 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Wed, 16 Sep 2026 22:18:57 -0400 Subject: [PATCH 10/11] fix(cwg): initialize JATOS adapter on load --- reference-game-cwg.html | 21 ++++++++++++++------- tests/jatos.test.mjs | 4 ++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/reference-game-cwg.html b/reference-game-cwg.html index cc51572..5bbce7d 100644 --- a/reference-game-cwg.html +++ b/reference-game-cwg.html @@ -178,9 +178,10 @@ }; const jsPsych = initJsPsych(); - const jatosAdapter = new jsPsychAdapterMultiplayerJatos({ - connectTimeoutMs: CONFIG.JATOS_CONNECT_TIMEOUT_MS, - }); + // jatos.studyResultId is populated by JATOS during onLoad. The adapter captures its + // participantId in the constructor, so constructing earlier makes every client write under an + // undefined/worker fallback key and breaks the per-member admission handshake. + let jatosAdapter = null; 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 @@ -231,7 +232,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: jatosAdapter.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, @@ -665,7 +666,7 @@ keeping the ?mp_session= in the URL.

`, choices: "NO_KEYS", on_load: () => { - const root = document.querySelector(".jspsych-html-keyboard-response-stimulus"); + 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:

@@ -1030,6 +1031,12 @@ jatos.onLoad(async () => { try { + jatosAdapter = new jsPsychAdapterMultiplayerJatos({ + connectTimeoutMs: CONFIG.JATOS_CONNECT_TIMEOUT_MS, + }); + if (jatosAdapter.participantId !== String(jatos.studyResultId)) { + throw new Error("JATOS studyResultId was unavailable when creating the multiplayer adapter."); + } await jsPsych.multiplayer.connect(jatosAdapter); DYAD_ID = jatosAdapter.groupId; if (!DYAD_ID) throw new Error("JATOS connected without a group ID"); @@ -1057,9 +1064,9 @@ ]); } catch (error) { console.error("[jatos] could not initialize group study", error); - DYAD_ID = `unassigned-${jatosAdapter.participantId ?? "anon"}`; + DYAD_ID = `unassigned-${jatosAdapter?.participantId ?? "anon"}`; SEED = DYAD_ID; - jsPsych.data.addProperties({ dyad_id: DYAD_ID, participant_id: jatosAdapter.participantId ?? null }); + jsPsych.data.addProperties({ dyad_id: DYAD_ID, participant_id: jatosAdapter?.participantId ?? null }); noMatch = true; noMatchReason = "adapter_connect_error"; jsPsych.run([noMatchScreen]); diff --git a/tests/jatos.test.mjs b/tests/jatos.test.mjs index c01a52e..00c8bbf 100644 --- a/tests/jatos.test.mjs +++ b/tests/jatos.test.mjs @@ -6,6 +6,10 @@ const results = []; const check = (name, ok) => results.push([ok ? "PASS" : "FAIL", name]); check("loads the pinned JATOS adapter", /adapter-multiplayer-jatos\.js/.test(html)); +const onLoadAt = html.indexOf("jatos.onLoad(async () =>"); +const adapterConstructAt = html.indexOf("new jsPsychAdapterMultiplayerJatos", onLoadAt); +check("constructs the adapter after JATOS onLoad", onLoadAt !== -1 && adapterConstructAt > onLoadAt); +check("updates the actual jsPsych lobby stimulus element", /#jspsych-html-keyboard-response-stimulus/.test(html)); check("derives dyad identity from groupId", /DYAD_ID = jatosAdapter\.groupId/.test(html)); check("uses presence for candidate membership", /jatosAdapter\.subscribePresence\(observe\)/.test(html)); check("polls current presence to close a missed-event window", /setInterval\(\(\) => observe\(\{ snapshot: jatosAdapter\.getPresence\(\) \}\), 250\)/.test(html)); From 933795d4e8e2c54cb8929e06737c4201edd5a0a9 Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Wed, 16 Sep 2026 22:29:57 -0400 Subject: [PATCH 11/11] fix(cwg): retain lobby timer during JATOS admission --- reference-game-cwg.html | 40 +++++++++++++++++++++++++++------------- tests/jatos.test.mjs | 3 +++ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/reference-game-cwg.html b/reference-game-cwg.html index 5bbce7d..fdf31dd 100644 --- a/reference-game-cwg.html +++ b/reference-game-cwg.html @@ -693,15 +693,26 @@ tick(); lobbyTicker = setInterval(tick, 1000); let settled = false; + let admissionStarted = false; let unsubscribe = () => {}; + let unsubscribeAdmission = () => {}; let presencePoll = null; - const finish = (reason, ids = []) => { - if (settled) return; - settled = true; + const stopLobby = () => { + clearTimeout(timer); clearInterval(lobbyTicker); lobbyTicker = null; if (presencePoll !== null) clearInterval(presencePoll); - unsubscribe(); - if (reason !== "matched") { noMatch = true; noMatchReason = reason; jsPsych.finishTrial({ lobby_outcome: reason }); return; } + unsubscribe(); unsubscribeAdmission(); + }; + const finishNoMatch = (reason) => { + if (settled) return; + settled = true; + stopLobby(); + noMatch = true; noMatchReason = reason; + jsPsych.finishTrial({ lobby_outcome: reason }); + }; + const beginAdmission = (ids) => { + if (settled || admissionStarted) return; + admissionStarted = true; admittedMemberIds = ids; const memberKey = ids.join("|"); admissionRecord = { group_id: DYAD_ID, member_key: memberKey, ready_at: Date.now() }; @@ -712,38 +723,41 @@ return record?.group_id === DYAD_ID && record?.member_key === memberKey; }); }; - const waitForBoth = () => new Promise((resolve, reject) => { + const waitForBoth = () => new Promise((resolve) => { if (bothReady()) return resolve(); - const timeout = setTimeout(() => { stop(); reject(new Error("admission handshake timed out")); }, CONFIG.JATOS_CONNECT_TIMEOUT_MS); - const stop = jatosAdapter.subscribe(() => { if (bothReady()) { clearTimeout(timeout); stop(); resolve(); } }); + unsubscribeAdmission = jatosAdapter.subscribe(() => { + if (bothReady()) { unsubscribeAdmission(); unsubscribeAdmission = () => {}; resolve(); } + }); }); jatosAdapter.push({ cwg_lobby_admission: admissionRecord }) .then(waitForBoth) .then(() => jatosAdapter.sealGroup()) .then(() => { + if (settled) return; const p = jatosAdapter.getPresence(); // Membership/leave is authoritative here. The completed two-sided write handshake // already proves both channels were usable; groupChannels can lag membership during // JATOS lifecycle transitions and must not undo a valid admission. const stillAssigned = ids.every((id) => p.assignedMemberIds.includes(id)); if (!stillAssigned) { noMatch = true; noMatchReason = "partner_left_before_seal"; } + settled = true; + stopLobby(); jsPsych.finishTrial({ lobby_outcome: noMatch ? noMatchReason : "matched", admitted_member_ids: ids }); }) .catch((error) => { + if (settled) return; console.error("[jatos] lobby admission failed", error); - noMatch = true; - noMatchReason = "admission_handshake_failed"; - jsPsych.finishTrial({ lobby_outcome: noMatchReason }); + finishNoMatch("admission_handshake_failed"); }); }; - const timer = setTimeout(() => finish("lobby_timeout"), CONFIG.LOBBY_TIMEOUT_MS); + const timer = setTimeout(() => finishNoMatch("lobby_timeout"), CONFIG.LOBBY_TIMEOUT_MS); const observe = ({ snapshot }) => { const ids = snapshot.assignedMemberIds; // Do not gate on openChannelMemberIds. A leave event / assigned membership is the // authoritative transition; the handshake below proves that both current members can // actually exchange group-session writes. const assignedPair = ids.length === MIN_PLAYERS; - if (assignedPair) { clearTimeout(timer); finish("matched", [...ids].sort()); } + if (assignedPair) beginAdmission([...ids].sort()); }; unsubscribe = jatosAdapter.subscribePresence(observe); // JATOS normally emits member callbacks, but a client can subscribe between its initial diff --git a/tests/jatos.test.mjs b/tests/jatos.test.mjs index 00c8bbf..f920a53 100644 --- a/tests/jatos.test.mjs +++ b/tests/jatos.test.mjs @@ -16,6 +16,9 @@ check("polls current presence to close a missed-event window", /setInterval\(\(\ check("does not gate admission on open-channel count", !/const live = ids\.length === MIN_PLAYERS && ids\.every\(\(id\) => snapshot\.openChannelMemberIds/.test(html)); check("requires matching records from both candidate ids", /ids\.every\(\(id\) => \{[\s\S]*cwg_lobby_admission/.test(html)); check("publishes readiness before sealing", /jatosAdapter\.push\(\{ cwg_lobby_admission: admissionRecord \}\)[\s\S]*?\.then\(waitForBoth\)[\s\S]*?\.then\(\(\) => jatosAdapter\.sealGroup\(\)\)/.test(html)); +check("keeps the lobby deadline while awaiting the second readiness record", /const timer = setTimeout\(\(\) => finishNoMatch\("lobby_timeout"\), CONFIG\.LOBBY_TIMEOUT_MS\);[\s\S]*?if \(assignedPair\) beginAdmission\(\[\.\.\.ids\]\.sort\(\)\);/.test(html)); +const admissionSource = html.slice(html.indexOf("const beginAdmission"), html.indexOf("const timer = setTimeout")); +check("does not use the short task-connect timeout for lobby readiness", !admissionSource.includes("CONFIG.JATOS_CONNECT_TIMEOUT_MS")); check("preserves admission record in role write", /cwg_lobby_admission: admissionRecord/.test(html)); for (const [status, name] of results) console.log(`${status} ${name}`);