From c3c18c2e122f9dfafb7c56b1ed937a5c632e6c9c Mon Sep 17 00:00:00 2001 From: Hannah Tsukamoto Date: Fri, 31 Jul 2026 14:04:21 -0400 Subject: [PATCH 1/2] feat(cwg): bound the lobby, and pay everyone who never got a game (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #6. Two ways a participant could reach a dead end where they could not submit and therefore could not be paid. The lobby had no timeout. jsPsychMultiplayerSync's `timeout` default is null, so wait() was called unbounded: someone who arrived and was never matched sat on that screen forever, with no exit, no completion code, and no way to be paid for time they had already given up. Now bounded by CONFIG.LOBBY_TIMEOUT_MS. Verified against the bundle rather than assumed: on timeout the sync trial RESOLVES with timed_out: true rather than aborting. So without a guard the timeline walks straight into roleTrial, which waits on its own `ready` predicate — swapping an unbounded lobby for an unbounded pairing screen one step later. pairingPhase gates that. The spectator path showed "this game is already full" for four seconds, disconnected, and gave NO completion code. Someone who arrived on time and was turned away for reasons entirely outside their control could not submit. It now routes to the same paid exit as a lobby timeout, because from the participant's side it is the same event. Both share one completion code, since Prolific configures a single code per exit, and stay separable in the data via `no_match_reason` (A7) — the arrival rate and the odd-arrival rate answer different questions and size #10's waiting room differently. The exit screen does not set trial_duration. The old spectator screen auto-dismissed after four seconds, which is fine for a screen that says nothing and fatal for one carrying a completion code. The lobby now shows a live countdown, because an indefinite-feeling wait is what makes people abandon, and states plainly that an unmatched participant is still paid. The amount comes from CONFIG.NO_MATCH_PAYMENT_USD and is templated into every screen that quotes it — participant-facing copy drifting from what is actually configured is a launch-checklist item, and a test now fails if any screen hardcodes a dollar figure. B2 and B6 are both publish-locked, so both are named constants with their reasoning: the timeout sets the advertised duration, and full-task-rate pay for the wait beats Prolific's $0.14/min floor because a floor-rate payment loses to returning the study, which teaches people to abandon the lobby exactly when we need them to stay. Adds tests/exits.test.mjs: 22 checks over the real conditional_function bodies, extracted from the file. Asserts that every reachable end state lands on EXACTLY ONE terminal screen — two means the participant sees a contradiction, zero means they cannot be paid, and this file has shipped both. Includes regressions for the two it actually shipped: a spectator reaching "Game complete!" with the complete code, and a dropout survivor doing the same. Co-Authored-By: Claude Opus 5 --- reference-game-cwg.html | 156 ++++++++++++++++++++++++++++++++++++---- tests/exits.test.mjs | 117 ++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 tests/exits.test.mjs diff --git a/reference-game-cwg.html b/reference-game-cwg.html index 816e0cb..f9658de 100644 --- a/reference-game-cwg.html +++ b/reference-game-cwg.html @@ -103,6 +103,24 @@ // every participant who does not click the download button contributes nothing. DATAPIPE_EXPERIMENT_ID: "", + // --- Lobby / no-match (#6) ------------------------------------------------------------------ + // B6 — how long a participant waits for a partner before being released and paid. NOT merely a + // UX number: it sets the advertised study duration (B5), which must include the expected wait, + // and it sets what the no-match payment below has to cover. Both are locked at publish, so + // 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, + + // 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 + // survey, which teaches people to abandon the lobby at exactly the moment we need them to + // stay. Costs ~$10-15 across a whole run. + // + // Defined ONCE and templated into every screen that quotes it. Participant-facing copy that + // disagrees with what is actually configured is the failure this constant exists to prevent, + // and it is on the launch checklist for that reason. + NO_MATCH_PAYMENT_USD: 1.25, + // --- Dropout detection (#5) --------------------------------------------------------------- // Consecutive SILENT rounds before concluding the partner is gone. A round counts as silent // only if it timed out AND the partner sent no chat message during it — see the detector for @@ -538,6 +556,14 @@ let partnerDropped = false; let dropoutRound = null; + // No-match state (#6). Two different ways of never getting into a game — waiting out the lobby, + // and being the odd arrival when a pair has already formed. They share ONE completion code, + // because Prolific configures a single code per exit, and stay separable in the data via + // `no_match_reason` (A7). The two rates answer different questions: one is about arrival rate, + // the other about odd-numbered bursts, and #10's waiting room is sized off both. + let noMatch = false; + let noMatchReason = 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). @@ -556,6 +582,10 @@ n_trials_completed: completedTrials(), n_trials_scheduled: TRIALS, dropout_detected_at_round: dropoutRound, + // Both no-match routes share one completion code (A7), so this is the only thing that keeps + // "nobody arrived" separable from "I was the odd one out" in the data. They answer different + // questions and are sized differently in #10. + no_match_reason: noMatchReason, }); } @@ -567,13 +597,54 @@ }, }; + const money = (usd) => `$${usd.toFixed(2)}`; + + let lobbyTicker = null; + const lobbyTrial = { type: jsPsychMultiplayerSync, push_data: () => ({ name: myName, joinedAt: Date.now() }), - message: `

Waiting for ${MIN_PLAYERS} players to join…

-

Open this page in another tab (keep the ?mp_session= in the URL) to add a player.

`, + // 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. + timeout: CONFIG.LOBBY_TIMEOUT_MS, + message: `

Waiting for a partner to join…

+

This study is played in pairs, so we need to match you with someone before we can + start. Most people are matched quickly.

+

Time remaining:

+

If we cannot find you a partner in time, we will + end the study and still pay you ${money( + CONFIG.NO_MATCH_PAYMENT_USD + )} 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. + on_load: () => { + const deadline = Date.now() + CONFIG.LOBBY_TIMEOUT_MS; + const el = document.getElementById("lobby-remaining"); + const tick = () => { + const left = Math.max(0, deadline - Date.now()); + const m = Math.floor(left / 60000); + const s = Math.floor((left % 60000) / 1000); + if (el) el.textContent = `${m}:${String(s).padStart(2, "0")}`; + }; + 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"; + } + }, }; const roleTrial = { @@ -590,26 +661,79 @@ myRole = jsPsychMultiplayerRole.getMyRole(); const byRole = jsPsychMultiplayerRole.participantsByRole(); partnerId = myRole === "director" ? byRole.matcher?.[0] : byRole.director?.[0]; + // The odd arrival. Previously a four-second "this game is already full" screen followed by + // disconnect and NO completion code — someone who showed up on time, was turned away for + // reasons entirely outside their control, and then could not submit or be paid. Routed to + // the same paid exit as a lobby timeout, because it is the same thing from the + // participant's point of view. + if (myRole === "spectator") { + noMatch = true; + noMatchReason = "spectator_overflow"; + } }, }; - const spectatorScreen = { + // roleTrial waits on its own `ready` predicate, so it must not run after a lobby timeout — that + // would swap an unbounded lobby for an unbounded pairing screen, which is the same bug one + // screen later. The sync plugin resolves rather than aborts on timeout, so this guard is what + // actually ends the session. + const pairingPhase = { + timeline: [roleTrial], + conditional_function: () => !noMatch, + }; + + // The unmatched exit (#6), shared by both no-match routes. Replaces a four-second screen that + // gave the spectator no completion code at all, and the lobby's total absence of an exit. + // + // `trial_duration` is deliberately NOT set: the previous spectator screen auto-dismissed after + // four seconds, which is fine for a screen that says nothing and fatal for one carrying a + // completion code. This one waits for the participant. + const noMatchScreen = { timeline: [ { type: jsPsychHtmlKeyboardResponse, - stimulus: "

This game is already full. Thanks for your interest!

", + stimulus: () => { + const waited = + noMatchReason === "spectator_overflow" + ? `

You arrived in time, but another pair had already formed, so there was no one + left for us to match you with. That is our scheduling problem, not anything you + did.

` + : `

We could not find you a partner in the time available. This happens when not + enough people happen to be online at the same moment — it is nothing to do with + you or your responses.

`; + return `

We could not match you with a partner

+ ${waited} +

You will still be paid ${money( + CONFIG.NO_MATCH_PAYMENT_USD + )} for your time. Please submit below so we can process it.

+

Saving…

+ ${submissionBlockHTML("no_match")}`; + }, choices: "NO_KEYS", - trial_duration: 4000, - 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"); + on_load: () => { + // Button first, always. This screen exists so that someone who never got to play can + // still be paid; nothing may sit between them and that. + wireSubmissionButton("no_match"); + recordOutcome(noMatchReason ?? "no_match"); jsPsych.multiplayer.disconnect(); + // Worth saving even with no game data: arrival time and the reason are the raw material + // for the arrival-rate and odd-arrival numbers that size #10's waiting room and set the + // lobby timeout (B6) for the real run. + Pipeline.flush(`no-match-${noMatchReason ?? "unknown"}`).then((r) => { + const el = document.getElementById("save-status"); + if (!el) return; + el.textContent = r.skipped + ? "" + : r.ok + ? "Saved." + : r.timedOut + ? "Still uploading — you can submit now, this will finish in the background." + : "Upload failed. Please submit anyway and message the researcher."; + }); }, }, ], - conditional_function: () => myRole === "spectator", + conditional_function: () => noMatch, }; const gameRound = { @@ -784,11 +908,13 @@ preloadTrial, nameTrial, lobbyTrial, - roleTrial, - spectatorScreen, + pairingPhase, gameLoop, - // 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). + // Exactly one of these three runs, and each carries its own completion code. Their + // conditions are mutually exclusive: noMatch excludes a role, a role excludes noMatch, and + // partnerDropped gates the complete screen. gameLoop's own conditional cannot end it + // mid-game, so the dropout abort comes from inside the round (#5). + noMatchScreen, partnerDroppedScreen, completeScreen, ]); diff --git a/tests/exits.test.mjs b/tests/exits.test.mjs new file mode 100644 index 0000000..ac98dcb --- /dev/null +++ b/tests/exits.test.mjs @@ -0,0 +1,117 @@ +// Tests for terminal-screen routing (#5, #6) in reference-game-cwg.html. +// +// node tests/exits.test.mjs +// +// Every way a session can end must land on EXACTLY ONE terminal screen, and each screen carries a +// different Prolific completion code. Two screens firing means a participant sees a contradiction; +// zero means they cannot submit and cannot be paid. Both have already happened in this file: +// a spectator used to fall through to "Game complete!" with the `complete` code, and a lobby +// timeout had no exit at all. +// +// The conditional_function bodies are extracted from the file and evaluated against each session +// state, so this tests the real routing rather than a description of it. + +import fs from "fs"; + +const html = fs.readFileSync(new URL("../reference-game-cwg.html", import.meta.url), "utf8"); +const scriptMatch = html.match(/