Skip to content

Bound the lobby, and pay everyone who never got a game (C&WG) - #18

Open
htsukamoto5 wants to merge 3 commits into
feat/dropout-abortfrom
feat/lobby-timeout-no-match
Open

Bound the lobby, and pay everyone who never got a game (C&WG)#18
htsukamoto5 wants to merge 3 commits into
feat/dropout-abortfrom
feat/lobby-timeout-no-match

Conversation

@htsukamoto5

Copy link
Copy Markdown
Member

Closes #6.

Stacked on #17. Review order is #14#16#17 → this. It reuses #17's exit-screen shape and #16's Pipeline.flush(), which is why it is small.

Two ways a participant could reach a dead end where they could not submit, and therefore could not be paid.

1. The lobby never ended

jsPsychMultiplayerSync's timeout default is null, so wait() was called unbounded. Someone who arrived and was never matched sat on that screen forever — no timeout, no exit, no completion code, no way to be paid for time they had already given up. They close the tab and open a support ticket.

Now bounded by CONFIG.LOBBY_TIMEOUT_MS.

One detail worth checking in review, verified against the bundle rather than assumed: on timeout the sync trial resolves with timed_out: true — it does not abort.

try { const t = yield l.wait(e.wait_for, s); yield o(), y(t, !1, null) }
catch (t) {  e.on_timeout(t), yield o(), y(a(), !0, t.message) }

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, which would have looked fixed while being the same bug. pairingPhase gates that.

2. The spectator was shown the door with no code

The overflow path showed "This game is already full" for four seconds, called disconnect(), and ended with 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: they showed up, and we could not give them a game.

Both routes share one completion code — Prolific configures a single code per exit — and stay separable in the data via no_match_reason (A7). That matters: the lobby-timeout rate and the odd-arrival rate answer different questions, and #10's waiting room is sized off both.

The exit screen deliberately 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.

Lobby copy

  • A live countdown, because an indefinite-feeling wait is what makes people abandon. The plugin renders message once and never updates it, so the ticker is driven from on_load and cleared in on_finish.
  • Plain statement that an unmatched participant is still paid, and how much.

The amount comes from CONFIG.NO_MATCH_PAYMENT_USD and is templated into every screen quoting it. Participant-facing copy drifting from what is actually configured is on the launch checklist, so there is now a test that fails if any screen hardcodes a dollar figure.

The two publish-locked decisions here

Both are suggestions, both are named constants with their reasoning inline, because neither can be changed once the study is published:

  • B6 — LOBBY_TIMEOUT_MS, 5 min. Not merely a UX number: it sets the advertised study duration (B5), which must include the expected wait. Refine from observed arrival rate in the pilot before publishing.
  • B2 — NO_MATCH_PAYMENT_USD, $1.25 (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.

Testing

node tests/exits.test.mjs22 checks over the real conditional_function bodies, extracted from the file rather than restated in the test.

The property under test is that every reachable end state lands on exactly one terminal screen. Two means the participant sees a contradiction; zero means they cannot be paid. This file has shipped both, so there are explicit regressions for each: a spectator reaching "Game complete!" with the complete code, and a dropout survivor doing the same.

Covers all six end states (director/matcher completing, either partner dropping, lobby timeout, spectator overflow), that pairing is skipped after a timeout, and the config sanity checks above.

tests/dropout.test.mjs 16/16 and tests/pipeline.test.mjs 39/39 still pass.

Not in here

🤖 Generated with Claude Code

htsukamoto5 and others added 3 commits July 31, 2026 14:04
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 <noreply@anthropic.com>
… end

Review of #6 found a session that lands on ZERO terminal screens.

The role plugin's `timeout` defaults to 30s -- `default: 3e4`, not null and not
unbounded -- and this file never sets it. On expiry it clears the assignment and
finishes with `role: null`, so `getMyRole()` returns undefined; the data row and
the accessor disagree. That state is neither a playable role nor "spectator", so
`noMatch` stayed false, all three screen conditionals evaluated false, and the
participant ran off the end of the timeline with no completion code and no way
to submit. The same dead end #6 exists to remove, reached one screen later.

It is reachable whenever `ready` stops being satisfiable mid-pairing: a partner
closes the lobby tab, or their own lobby timed out a tick earlier and they have
since disconnected. This predates #5 and #6 and produced a codeless exit then
too -- what changed is that a paid exit now exists and this path simply did not
route to it.

roleTrial.on_finish now routes by exclusion rather than by listing the failures,
as `pairing_timeout`, kept separate from `spectator_overflow` because the rates
answer different questions: odd-numbered bursts versus ghost entries in the
lobby (#10).

The test gap mattered more than the bug. The exits matrix took `noMatch` as a
given, so it proved which screen fires for a state but never which sessions
arrive in it -- which is how this survived review of both PRs. It now extracts
the real guard from on_finish and drives it, with regressions for undefined AND
null so nobody re-fixes this by comparing against whichever one they saw first.

Also corrects the pairingPhase comment, which claimed roleTrial would wait
unboundedly. It would not; it would waste 30s and finish with a null role. The
guard is still right, for that reason instead.

Also: the lobby countdown runs slightly ahead of the real deadline, because
on_load fires before `push_data` round-trips and the plugin only arms its timer
at `wait()`. It now says "finishing up…" for the last second rather than sitting
at a stopped 0:00, which is the indefinite-feeling wait it exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant