Bound the lobby, and pay everyone who never got a game (C&WG) - #18
Open
htsukamoto5 wants to merge 3 commits into
Open
Bound the lobby, and pay everyone who never got a game (C&WG)#18htsukamoto5 wants to merge 3 commits into
htsukamoto5 wants to merge 3 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'stimeoutdefault isnull, sowait()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.So without a guard the timeline walks straight into
roleTrial, which waits on its ownreadypredicate — swapping an unbounded lobby for an unbounded pairing screen one step later, which would have looked fixed while being the same bug.pairingPhasegates 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
messageonce and never updates it, so the ticker is driven fromon_loadand cleared inon_finish.The amount comes from
CONFIG.NO_MATCH_PAYMENT_USDand 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:
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.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.mjs— 22 checks over the realconditional_functionbodies, 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
completecode, 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.mjs16/16 andtests/pipeline.test.mjs39/39 still pass.Not in here
disconnect()— no heartbeat, nobeforeunloadin any bundle — so a lobby tab closed mid-wait leaves an entry that can satisfywait_forand produce a match with nobody. That mainly bites on Firebase and is tracked with Pairing: build a rolling waiting room (room bucketing, ghost expiry, odd arrivals) #10.no_matchis still an empty placeholder code until Create the DataPipe experiment and a draft Prolific study — critical path #15, so this path currently shows the configuration-error screen rather than a code, and warns at startup.🤖 Generated with Claude Code