Skip to content

feat(plugin-multiplayer-vote): add anonymous group-vote primitive - #40

Closed
Mandyx22 wants to merge 4 commits into
mainfrom
feat/plugin-vote
Closed

feat(plugin-multiplayer-vote): add anonymous group-vote primitive#40
Mandyx22 wants to merge 4 commits into
mainfrom
feat/plugin-vote

Conversation

@Mandyx22

Copy link
Copy Markdown
Contributor

Summary

Adds plugin-multiplayer-vote — an anonymous group poll, the last of the Tier-1/2 multiplayer primitives (after live-scoreboard #36, choice #37, match #38, turn #39).

Every participant votes for one of the same options; the trial pushes that vote and barriers until all expected_players have voted, then optionally reveals the aggregate tally and the plurality winner.

How it differs from plugin-multiplayer-choice

  • Aggregate, not individual — the emphasis is per-option counts + a winner, not who chose what.
  • Anonymous by construction — the data and reveal carry per-option counts (tally/winner/tied_options), never a participant→vote mapping. The tally is labelled from the trial's own trusted choices, so no untrusted per-voter string is ever exposed. Each client still records/sees only its own pick (vote/vote_index + a "(you)" reveal highlight).
  • Plurality winner — most votes wins; a shared top count is a tie (winner: null, is_tie: true, tied_options lists the leaders); zero votes → winner: null, not a tie.

Design

Barrier-then-reveal trial on the multiplayer API (a leaner choice, minus payoff/player_label):

  • Pure core (vote-core.ts, jsPsych-free, exposed as statics): readVote, countVoted, tally, plurality.
  • The barrier counts only in-range votes (countVoted(g, dataKey, choices.length)) so it always agrees with the tally — a stale/out-of-range index (e.g. left under a reused data_key) neither lifts the barrier early nor is dropped from n_votes.
  • Supports a wait timeout (proceeds partial, flags timed_out), reveal: false, and custom button_html.

Tests

  • vote-core.spec.ts + index.spec.ts34 tests (unit + MockApi barrier flow + startTimeline smoke test), including regression tests for the in-range barrier count.
  • Repo-wide: 321 tests / 14 suites pass.

Review

Went through three high-effort /code-review passes; 6 findings surfaced and fixed (barrier/tally consistency, anonymity doc precision, tie-break example robustness, tie-state styling), final pass clean.

🤖 Generated with Claude Code

Mandyx22 and others added 4 commits July 10, 2026 16:57
Add `plugin-multiplayer-vote`: an anonymous group poll. Every participant
votes for one of the same options; the trial pushes that vote and barriers
until all `expected_players` have voted, then optionally reveals the
aggregate tally and the plurality winner.

Unlike plugin-multiplayer-choice, the emphasis is the aggregate rather than
the individual pick, and the ballot is anonymous: the data and reveal carry
per-option counts and the winner, never a participant -> vote mapping (the
tally is labelled from the trial's own `choices`, so no untrusted per-voter
string is exposed). Winner is plurality, with `is_tie`/`tied_options` when
the top count is shared and `winner: null` when nobody voted.

Built as a barrier-then-reveal trial on the multiplayer API; supports a wait
`timeout` (proceeds partial, flags `timed_out`), `reveal: false`, custom
`button_html`, and exposes the pure core (tally/plurality/countVoted) as
statics. The barrier counts only in-range votes so it agrees with the tally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A self-contained demo under examples/ that composes adapter-multiplayer-local
+ plugin-multiplayer-sync + plugin-multiplayer-vote: name entry -> lobby ->
anonymous ballot -> tally/winner reveal. Runs entirely from two browser tabs
with no server, following the chat-room.html / draw-room.html pattern. Verified
end-to-end (two participants, winner rendered identically, no console errors).

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

- Replace the live head-count `expected_players: () => namedPresent(...)` with a
  fixed EXPECTED_PLAYERS constant so all clients agree on how many votes the
  barrier waits for, 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, winner Sci-Fi 2/2, no console errors).

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

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 tally (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 tally; 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).

jodeleeuw added a commit that referenced this pull request Jul 20, 2026
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

Closing in favor of #37: side-by-side review showed vote shared choice's entire choose → push → barrier → reveal engine and added no unique parameters, so its functionality has been folded into plugin-multiplayer-choice as reveal_mode: "tally" + record_choices_by_player: false (commit 6d0d449 on #37). The tally reveal, tally()/plurality() core, bounded barrier count, and tests were ported essentially verbatim — thanks @Mandyx22, that work all ships, just under one package. See the update comment on #37 for details.

@jodeleeuw jodeleeuw closed this Jul 20, 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