_chat` when it is on, so turning it on makes `message_count`
+ // CUMULATIVE: `partnerMessages` never returns to zero, the detector never fires again, and
+ // the survivor grinds — the exact failure this exists to prevent, arrived at silently.
+ // 2. `ended_by: "timeout"` is only equivalent to "the round clock ran out" because
+ // `selection_timeout` is unset. The plugin emits the same string for a selection timeout,
+ // which is a different event with a possibly-present partner.
+ //
+ // Upgrade path: when a presence API exists (#11), this becomes a fallback rather than the
+ // primary signal. It does not need to wait for one. See CONFIG for the false-positive channel
+ // this cannot close — an active but silent matcher — and what to do if the pilot shows it.
+ // ===============================================================================================
+ let consecutiveSilentRounds = 0;
+ let partnerDropped = false;
+ let dropoutRound = null;
+
+ // Trials the dyad actually completed, as opposed to rounds the timeline advanced through. Only
+ // `ended_by: "submit"` counts — a timed-out round has a null assignment and is not a trial.
+ // This is what makes a partial dyad usable rather than merely present (#8).
+ const completedTrials = () =>
+ jsPsych.data
+ .get()
+ .filter({ trial_type: "multiplayer-reference-game", ended_by: "submit" })
+ .count();
+
+ // Session-level facts, stamped onto every row at the point the game ends by any route. Recorded
+ // here rather than left to analysis because "how did this session end" cannot be reconstructed
+ // reliably from partial data — which is precisely the data most likely to be all we have.
+ //
+ // First writer wins. `addProperties` applies retroactively to every row already collected, so a
+ // second call would not merely add a row — it would rewrite the FIRST outcome on every existing
+ // row and destroy the reason the session actually ended. Exactly one outcome is recorded per
+ // session today; this makes that structural rather than a property of the current routing.
+ let endedReason = null;
+ function recordOutcome(reason) {
+ if (endedReason) return;
+ endedReason = reason;
+ jsPsych.data.addProperties({
+ ended_reason: reason,
+ n_trials_completed: completedTrials(),
+ n_trials_scheduled: TRIALS,
+ dropout_detected_at_round: dropoutRound,
+ });
+ }
+
const nameTrial = {
type: jsPsychSurveyText,
questions: [{ prompt: "Choose a display name:", required: true, name: "name" }],
@@ -546,6 +638,7 @@
on_finish: () => {
// Flush even here. A spectator has no game data, but their arrival and the fact they were
// turned away is the raw material for the odd-arrival rate #10's waiting room must handle.
+ recordOutcome("spectator");
if (CONFIG.FLUSH_ON_ABORT) Pipeline.flush("spectator");
jsPsych.multiplayer.disconnect();
},
@@ -583,6 +676,23 @@
// Deliberately not awaited: the next round must not wait on OSF.
on_finish: (data) => {
if (CONFIG.SAVE_PER_ROUND) Pipeline.save(`round-${data.round ?? "?"}`);
+
+ // Partner activity in THIS round. `message_count` is every message on the round's channel
+ // and `messages_sent` is mine, so the difference is theirs. A round that timed out while the
+ // partner was still talking is a slow round, not a dropout.
+ const partnerMessages = (data.message_count ?? 0) - (data.messages_sent ?? 0);
+ const silent = data.ended_by === "timeout" && partnerMessages <= 0;
+ consecutiveSilentRounds = silent ? consecutiveSilentRounds + 1 : 0;
+
+ if (!partnerDropped && consecutiveSilentRounds >= CONFIG.DROPOUT_SILENT_ROUNDS) {
+ partnerDropped = true;
+ dropoutRound = data.round ?? null;
+ recordOutcome("partner_dropped");
+ // `conditional_function` on gameLoop is evaluated ONCE, before the first round, so it
+ // cannot end the loop mid-game. Aborting the enclosing timeline from here is what actually
+ // stops the remaining rounds; execution resumes at the screens after gameLoop.
+ jsPsych.abortCurrentTimeline();
+ }
},
prompt: (role) =>
role === "director"
@@ -596,6 +706,46 @@
conditional_function: () => myRole === "director" || myRole === "matcher",
};
+ // The survivor's exit (#5). Honest about what happened, explicit that they are still paid, and
+ // it never blames them. This screen is the entire difference between a survivor who submits and
+ // one who abandons and opens a support ticket.
+ const partnerDroppedScreen = {
+ timeline: [
+ {
+ type: jsPsychHtmlKeyboardResponse,
+ stimulus: () => {
+ const played = completedTrials();
+ return `Your partner seems to have disconnected
+ We waited, but your partner stopped responding, so we have ended the study here.
+ This is not your fault, and your payment is not affected.
+ You completed ${played} of ${TRIALS} round${played === 1 ? "" : "s"} together, and
+ ${played > 0 ? "that work is saved and still useful to us" : "your time is still paid"}.
+ Saving your responses…
+ ${submissionBlockHTML("partner_dropped")}`;
+ },
+ choices: "NO_KEYS",
+ on_load: () => {
+ // Wire the button FIRST. The participant must be able to submit immediately, whatever
+ // the save is doing — the whole point of this screen is that they get paid.
+ wireSubmissionButton("partner_dropped");
+ jsPsych.multiplayer.disconnect();
+ Pipeline.flush("abort-partner-dropped").then((r) => {
+ const el = document.getElementById("save-status");
+ if (!el) return;
+ el.textContent = r.skipped
+ ? ""
+ : r.ok
+ ? "Your responses have been saved."
+ : r.timedOut
+ ? "Still uploading — you can submit now, this will finish in the background."
+ : "Your responses could not be uploaded. Please submit anyway and message the researcher.";
+ });
+ },
+ },
+ ],
+ conditional_function: () => partnerDropped,
+ };
+
const doneTrial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: () => {
@@ -618,6 +768,7 @@
choices: "NO_KEYS",
on_load: () => {
wireSubmissionButton("complete");
+ recordOutcome("complete");
// Final flush: the last round's rows, plus anything an earlier chunk failed to send. Fired
// on_load rather than on_finish because this trial takes no keys and never finishes — the
@@ -650,6 +801,17 @@
},
};
+ // `doneTrial` used to run unconditionally, which meant two people who never finished the game
+ // still reached "Game complete!" and were handed the `complete` code: the survivor of a dropout,
+ // and — already, before this change — any spectator, who saw a 4-second "game is full" screen and
+ // then a completion screen reporting 0 / 0. Both are paid under the wrong code, which defeats the
+ // point of having four, and the spectator case also contradicts what the screen before it said.
+ const completeScreen = {
+ timeline: [doneTrial],
+ conditional_function: () =>
+ !partnerDropped && (myRole === "director" || myRole === "matcher"),
+ };
+
jsPsych.multiplayer.connect(localAdapter).then(() => {
// Preload FIRST, before pairing: a participant who is still fetching images while their
// partner waits in the lobby wastes the partner's time.
@@ -660,7 +822,10 @@
roleTrial,
spectatorScreen,
gameLoop,
- doneTrial,
+ // Exactly one of these two runs, and each carries its own completion code. gameLoop's own
+ // conditional cannot end it mid-game, so the abort comes from inside the round (#5).
+ partnerDroppedScreen,
+ completeScreen,
]);
});
diff --git a/tests/dropout.test.mjs b/tests/dropout.test.mjs
new file mode 100644
index 0000000..a76273a
--- /dev/null
+++ b/tests/dropout.test.mjs
@@ -0,0 +1,170 @@
+// Tests for dropout detection (#5) in reference-game-cwg.html.
+//
+// node tests/dropout.test.mjs
+//
+// Extracts the real `on_finish` body from the gameRound definition and drives it with synthetic
+// round data, so the detector is tested as written rather than as re-implemented here.
+//
+// The thing under test is a judgement call with an asymmetric cost: a false positive ends a live
+// dyad and pays two partial codes, a false negative leaves a survivor grinding. The cases below are
+// mostly about the false-positive side, because that is the one that silently destroys good data.
+
+import fs from "fs";
+
+const html = fs.readFileSync(new URL("../reference-game-cwg.html", import.meta.url), "utf8");
+const scriptMatch = html.match(/