diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/JATOS-ADAPTER-VALIDATION.md b/JATOS-ADAPTER-VALIDATION.md new file mode 100644 index 0000000..ff2d281 --- /dev/null +++ b/JATOS-ADAPTER-VALIDATION.md @@ -0,0 +1,82 @@ +# 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 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: + +- 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..62e1f33 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 @@ -58,3 +64,10 @@ For a paid Prolific run, swap `adapter-multiplayer-local` for 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/reference-game-cwg.html b/reference-game-cwg.html index 432aef0..fdf31dd 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,10 @@ }; const jsPsych = initJsPsych(); - const localAdapter = new jsPsychAdapterMultiplayerLocal({ persistParticipant: true }); + // 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 @@ -188,10 +193,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 +222,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 +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: 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 +381,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; @@ -635,10 +641,15 @@ 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 = { - 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, + 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. @@ -653,11 +664,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.
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 +692,98 @@ }; 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 admissionStarted = false; + let unsubscribe = () => {}; + let unsubscribeAdmission = () => {}; + let presencePoll = null; + const stopLobby = () => { + clearTimeout(timer); + clearInterval(lobbyTicker); lobbyTicker = null; + if (presencePoll !== null) clearInterval(presencePoll); + 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() }; + 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) => { + if (bothReady()) return 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); + finishNoMatch("admission_handshake_failed"); + }); + }; + 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) beginAdmission([...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); }, }; 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 { + // 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 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); + } + } 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 +808,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 +882,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 +1043,23 @@ !partnerDropped && (myRole === "director" || myRole === "matcher"), }; - jsPsych.multiplayer.connect(localAdapter).then(() => { + 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"); + 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 +1076,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]); + } });