Skip to content

feat: add plugin-multiplayer-choice - #37

Merged
jodeleeuw merged 6 commits into
mainfrom
feat/plugin-choice
Jul 20, 2026
Merged

feat: add plugin-multiplayer-choice#37
jodeleeuw merged 6 commits into
mainfrom
feat/plugin-choice

Conversation

@Mandyx22

@Mandyx22 Mandyx22 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new package, @jspsych-multiplayer/plugin-multiplayer-choice — a simultaneous group-decision primitive. Every participant picks one of the same options; the trial pushes that pick and waits (a barrier) until all expected_players have chosen, then optionally reveals the outcome — either everyone's choices attributed (reveal_mode: "players", default) or an anonymous aggregate tally + plurality winner (reveal_mode: "tally"). Tally mode absorbs the separately-proposed plugin-multiplayer-vote (#40), which shared this plugin's entire engine and differed only in reveal/data shape.

It is the engine under simultaneous-move paradigms — prisoner's dilemma, public-goods contributions, dictator/coordination games — packaging the choose → push → wait → reveal flow as one declarative trial. It sits alongside the other primitives (sync, role, chat): like plugin-multiplayer-sync it's a barrier (push → wait), but it owns the option UI and the "everyone has chosen" condition and adds a reveal.

Lifecycle (3 phases)

  1. Choose — render the choices as buttons; the participant clicks one (records choice/choice_index/rt). The click listener is on the option container (like jsPsych's html-button-response), so a custom button_html that renders non-<button> markup is still selectable.
  2. Barrier — push the pick (preserving other slot keys, since push replaces the slot) and wait until countChosen >= expected_players. On timeout it proceeds with a partial group, flags timed_out, and calls on_timeout (a push failure, by contrast, surfaces loudly — not relabeled as a timeout).
  3. Reveal (optional, reveal: true) — list every player's choice (self highlighted, peer labels escaped), show the optional payoff, end on a continue button and/or reveal_duration.

Design notes

  • Pure core (collectChoices, countChosen, readChoice) in choice-core.ts with its own spec, exposed as statics — the deterministic "who chose what" over a snapshot, testable without a live session.
  • Optional payoff(choices, me) => number hook — off by default, so the plugin stays a pure decision primitive; with no hook you derive payoffs from choices_by_player in on_finish.
  • Overwrite-per-participant preserved: the choice is merged into this client's existing slot, so a prior role/chat push survives.

Parameters

choices (required), expected_players (required), plus prompt, button_html, data_key, waiting_message, timeout, on_timeout, reveal, reveal_mode, reveal_prompt, continue_label, reveal_duration, player_label, payoff, record_choices_by_player. Data: choice, choice_index, rt, wait_time, choices_by_player (null when not recorded), n_players, tally, winner, is_tie, tied_options, my_payoff, timed_out, wait_error.

Testing

  • New package: 44 tests (25 plugin + 19 pure-core, incl. the tally/tie/anonymity/stale-pick suites ported from feat(plugin-multiplayer-vote): add anonymous group-vote primitive #40) — choose/barrier/reveal flow, reveal:false, timeout → partial, payoff (incl. throwing), push-failure propagation, slot-key preservation, HTML escaping, button_html with non-<button> markup, reveal_duration auto-advance, and a startTimeline smoke test through real jsPsych.
  • tsc + rollup build clean; repo-root npm run build exit 0; repo-wide npm test 299 passing / 13 suites; prettier clean.
  • Changeset (minor) included.

🤖 Generated with Claude Code

Add a new package for a simultaneous decision: every participant picks one of
the same options, the trial pushes that pick and waits (a barrier) until all
expected_players have chosen, then optionally reveals everyone's choices. It is
the engine under simultaneous-move paradigms — prisoner's dilemma, public-goods
contributions, dictator/coordination games — packaging the choose → push → wait
→ reveal flow as one declarative trial.

Includes a timeout that degrades to a partial group, an optional payoff(choices,
me) hook (off by default, so the plugin stays a pure decision primitive), the
button_html/player_label display hooks, the pure core (collectChoices/countChosen)
exposed as statics, docs, an example, and 27 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mandyx22 and others added 4 commits July 13, 2026 16:42
… tabs)

A self-contained demo under examples/ that composes adapter-multiplayer-local
+ plugin-multiplayer-sync + plugin-multiplayer-choice: name entry -> lobby ->
simultaneous Cooperate/Defect -> attributed reveal + PD payoffs. Runs entirely
from two browser tabs with no server, following the chat-room.html pattern.
Exercises player_label (lobby names on the reveal) and the payoff hook (the PD
matrix). Verified end-to-end (asymmetric round: defector scored 5, cooperator 0,
no console errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nup, note

- Replace the live head-count `expected_players: () => namedPresent(...)` with a
  fixed EXPECTED_PLAYERS constant, so all clients agree on the barrier size
  instead of hanging when tabs reach it at different moments.
- Add a `pagehide` handler so a closed tab removes its slot rather than lingering
  as a ghost across runs.
- State the required player count on the first screen (survey-text preamble).

Verified end-to-end (2 tabs, asymmetric PD round 5/0, no console errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…jections

jsPsych#3694 rejects a barrier timeout with a typed MultiplayerTimeoutError,
but wait() can also reject if the condition predicate throws or the backend
fails. Only a real timeout now degrades to a partial snapshot (timed_out,
on_timeout); any other rejection rethrows so the trial halts loudly instead
of being mislabelled a timeout. Mock rejects with the named error to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@htsukamoto5

Copy link
Copy Markdown
Member

Pushed a robustness fix for the latest jsPsych#3694. The barrier's catch treated any api.wait() rejection as a timeout (timed_out: true + on_timeout), but #3694 now rejects a real timeout with a typed MultiplayerTimeoutError — and a wait() can also reject because the condition predicate throws or the backend fails. Those non-timeout faults were being silently mislabelled as a timeout.

Changes (commit on this branch):

  • The catch now rethrows unless error.name === "MultiplayerTimeoutError". A genuine timeout still degrades to a partial snapshot; any other rejection halts the trial loudly. (Matching on error.name rather than instanceof survives two loaded copies of jspsych.)
  • Set the test mock's timeout rejection to name = "MultiplayerTimeoutError" so it mirrors the real API, and added a regression test that a non-timeout wait() rejection propagates instead of being masked.

The existing push-failure test already covered the pre-wait path; this extends the same guarantee to the wait itself. tsc clean, tests + build green, patch changeset added. Same pattern as plugin-multiplayer-sync/-role (#45).

Fold plugin-multiplayer-vote (PR #40) into plugin-multiplayer-choice — the
two shared an identical choose → push → barrier → reveal engine and vote
added no unique parameters, only a different reveal and data shape.

- reveal_mode: "players" (default, attributed roster) | "tally" (anonymous
  per-option counts + plurality winner + tie summary, ported verbatim from
  vote). Invalid values throw rather than silently coerce.
- record_choices_by_player: false drops the participant → choice map from
  the recorded data for output-anonymous polls. Docs state explicitly that
  this is output-level anonymity only — peers' raw picks remain readable in
  the shared session state by an inspecting client.
- Always record the aggregate: tally / winner / is_tie / tied_options
  (choice-core gains tally() + plurality(), exposed as statics).
- Adopt vote's option-range-bounded barrier count: a stale out-of-range pick
  under a reused data_key can no longer lift the barrier or skew n_players.
- Disable the option CONTAINERS (not just inner <button>s) after a pick, so
  custom button_html without a <button> also greys out.
- Add author to package.json; port vote's tally/tie/anonymity/stale-pick
  tests (44 tests total); add examples/poll-room.html (two-tab movie-night
  poll) and a tally example to the package example.
- Regenerate package-lock.json from a clean npm install: the previous lock
  diff had stripped resolved/integrity fields from 808 entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jodeleeuw

Copy link
Copy Markdown
Member

Update (6d0d449): this PR now absorbs plugin-multiplayer-vote (#40) as a reveal mode.

Review of the two plugins side-by-side showed vote shared an identical choose → push → barrier → reveal engine with choice and added zero unique parameters — its entire identity was the reveal rendering and data shape. Rather than ship two ~90%-duplicate packages, vote's functionality now lives here:

  • reveal_mode: "players" | "tally""players" (default) is the existing attributed roster; "tally" is vote's aggregate reveal (per-option count bars, plurality winner, tie summary, "(you)" marker), ported verbatim. Invalid values throw rather than silently coerce.
  • record_choices_by_player: false — drops the participant → choice map from the recorded data for anonymous polls. Kept separate from reveal_mode so "participants see only the tally, researcher keeps attributed data" remains expressible.
  • Aggregate data always recorded: tally / winner / is_tie / tied_options; the core gains tally() + plurality() as statics.
  • Adopted vote's option-range-bounded barrier count unconditionally — a stale out-of-range pick under a reused data_key can no longer lift the barrier early or skew n_players.
  • Anonymity documented honestly: the README now states this is output-level anonymity (reveal + recorded data), not transport-level — peers' raw picks remain in the shared session state, readable by an inspecting client. True unlinkability would need server-side aggregation.

Also in this push:

  • Regenerated package-lock.json from a clean npm install on top of main — the previous lock diff had stripped resolved/integrity fields from 808 entries (an offline-install artifact; same issue exists on feat: add plugin-multiplayer-match #38/feat: add plugin-multiplayer-countdown (synchronized group timer) #41).
  • Fixed the two review nits: author added to package.json; option containers now disable/grey after a pick (not just inner <button>s, which missed custom button_html markup).
  • Tests: 27 → 44 (vote's tally/tie/anonymity/stale-pick suites ported); new examples/poll-room.html two-tab demo; README/docs updated.

#40 is being closed in favor of this PR. One naming note for the release: the CITATION.cff/package author is listed as "Mandy Liao" — @Mandyx22 please confirm that's the name you want on the published package.

@jodeleeuw
jodeleeuw merged commit 2b24e0e into main Jul 20, 2026
4 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

📦 New package — trusted-publishing bootstrap needed

This PR added one or more new packages. npm trusted publishing (OIDC) can't be configured for a package that doesn't exist yet, so a maintainer must do a one-time bootstrap per new package. After that, releases publish automatically via OIDC (publish.yml).

Before running the commands below: npm >=11.15.0 (npm install -g npm@latest) — required for npm trust; older versions fail the trust step with HTTP 400. Also 2FA enabled, logged in (npm login), with publish access to the @jspsych-multiplayer scope.

@jspsych-multiplayer/plugin-multiplayer-choice

From a fresh checkout of main:

npm ci && npm run build
npm publish -w @jspsych-multiplayer/plugin-multiplayer-choice --access public
npm trust github @jspsych-multiplayer/plugin-multiplayer-choice --repo jspsych/jspsych-multiplayer --file publish.yml --allow-publish

Once bootstrapped, bump the version and merge to mainpublish.yml publishes future versions tokenlessly via OIDC, with provenance.

@github-actions github-actions Bot mentioned this pull request Jul 13, 2026
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.

3 participants