Skip to content

feat: add plugin-multiplayer-role (deterministic role assignment)#15

Open
htsukamoto5 wants to merge 10 commits into
mainfrom
plugin-multiplayer-role
Open

feat: add plugin-multiplayer-role (deterministic role assignment)#15
htsukamoto5 wants to merge 10 commits into
mainfrom
plugin-multiplayer-role

Conversation

@htsukamoto5

Copy link
Copy Markdown
Member

Summary

Adds @jspsych-multiplayer/plugin-multiplayer-role, a role-assignment plugin for the jsPsych multiplayer API. It deterministically maps participants in a shared group session to roles, so every client independently computes the same assignment without a coordinator — the consensus problem researchers otherwise hand-roll.

This is a new community plugin I built on my own initiative, as a reusable primitive for multiplayer (especially turn-based economic-game) experiments.

What it does

  • Declare role names and counts — ["proposer", "responder"] or { leader: 1, follower: 3 } — and pick a strategy:
    • join_order, shared-seeded random, rotate (per-round, with a balanced Williams-sequence variant for frequency + first-order carryover balance)
    • escape hatches: rank_by / role_from / a fully custom function
  • A readiness gate (exact-count, fail-loud) holds the trial until the group is complete, then assigns over the resolved snapshot (no time-of-check/time-of-use gap).
  • Downstream trials read the result via statics: getMyRole() / getRoleMap() / participantsByRole().
  • Output shape id -> { role, group? } reserves group for future partitioning.

Design notes

  • Pure core, thin wrapper. The hard part — deterministic consensus — lives in a dependency-free assignRoles() core with exhaustive property tests. The plugin wraps it plus the readiness gate.
  • No build-time dependency on unreleased core. The multiplayer API ships in jsPsych#3694 (not yet released), so the wrapper codes against a local interface mirroring MultiplayerAPI and reaches the real object with one cast — the single seam to re-verify once #3694 lands. Tests run against an in-memory mock.
  • Config errors (overflow with no overflow_role, a role_from returning an undeclared role, a throwing custom fn) propagate loudly rather than being mislabelled as a readiness timeout.
  • Public API is exposed as statics on the default export (named runtime re-exports break the contrib rollup config, which hardcodes output.exports: "default").

Testing

164 tests green (pure core + property tests + wrapper), tsc clean, rollup build green.

Independence

This plugin sidesteps the unreleased core via its local interface, so it does not depend on the sync/jatos migration PRs and can be reviewed/merged on its own.

🤖 Generated with Claude Code

htsukamoto5 and others added 7 commits June 25, 2026 16:21
…tests

Scaffold plugin-multiplayer-role and implement the API-independent layer:
- roles.ts: deterministic assignRoles (join_order/random/rotate/rankBy/
  roleFrom/custom), shared-seeded shuffle, loud overflow handling.
- readiness.ts: makeReadiness gate (exact-count + strategy-derived field
  readiness; throwing speculative accessors treated as "not ready").
- store.ts: getMyRole/getRoleMap accessor + participantsByRole inverter.
- roles.spec.ts + readiness.spec.ts: 23 tests, all passing.

Plugin wrapper (index.ts) deferred until jsPsych PR #3694 merges, since it
is the only piece that depends on the multiplayer API surface. Design doc
in docs/role-assignment-plugin-plan.md.

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

Replace the "balanced rotation not implemented" stub with a real
Williams-design Latin-square rotation: balancedRotationShift(n, round)
shifts the base order by the round'th term of the balanced sequence
0, n-1, 1, n-2, ... instead of by `round`. This keeps the per-round
frequency guarantee for all n and additionally balances first-order
carryover across the group (exact for even n). Pure in (n, round), so
the consensus property is preserved.

Add tests covering the Williams sequence, divergence from plain rotate,
frequency balance, cross-client consensus, the carryover proof (all 12
ordered role-pairs occur once for n=4), and edge sizes.

Replace the placeholder README with full docs: param/data tables,
strategies (with strategy-vs-rank_by/role_from precedence), the
rotate/balanced section, accessor usage, and the membership-consensus
caveat. A Status note flags the trial wrapper as pending jsPsych#3694.

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

Add roles.property.spec.ts covering the consensus core's defining
invariants, checked exhaustively where cheap (every snapshot key
permutation for n <= 6) and with representative reorderings beyond:
  - consensus invariance to snapshot key order, for every strategy
    (incl. tie-break cases where joinedAt/score are all equal);
  - every assignment is a bijection over the declared roles;
  - rotate (plain and balanced) sweeps each role to each participant
    exactly once per n consecutive rounds, for all n;
  - balanced rotate balances first-order carryover for even n (all
    n*(n-1) ordered role pairs occur once) — confirmed up to n=8.

Chose exhaustive/representative loops over a fast-check dependency: the
input space is small and structured (behaviour fixed by n and round mod
n), so exhaustive small-n is stronger evidence than random sampling and
adds no dependency surface to the monorepo.

Also fill in the CITATION.cff author/contact (Hannah Tsukamoto + ORCID);
release-time fields (version/doi/date/url) left as template TODOs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ics; honest deferred stub

Make the package honest about what it ships without breaking the jsPsych
single-default-export build convention:

- Expose the pure core and role accessors as STATIC members of the
  plugin class (MultiplayerRole.assignRoles / .getMyRole / .getRoleMap /
  .participantsByRole). Reachable through the one default export, so the
  rollup `exports: "default"` convention (and CJS/IIFE ergonomics) stays
  intact — named runtime re-exports would have broken the build. Public
  types stay as type-only exports (erased at build, so they don't count).
- Replace the scaffold stub: empty the placeholder param/data schema and
  make trial() throw a clear "wrapper not implemented, pending jsPsych
  #3694" error instead of silently finishing with junk data. Real JSDoc.
- Replace the misleading "should load" test (which only exercised the
  stub) with tests of the real surface: statics present, accessors empty
  pre-assignment, assignRoles works through the public path, trial throws.
- README: import default + use MultiplayerRole.<helper> static access.
- Metadata: real package.json description/author; CITATION version 0.0.0
  -> 0.0.1 to match package.json (other release fields stay TODO).

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

Replace the deferred throwing stub with the real trial() + handleTimeout()
per plan §6, built against a local MultiplayerApiLike interface (new
src/multiplayer-api.ts) so there is no build-time dependency on the unmerged
jsPsych#3694. Restore the full info.parameters and info.data schema.

Flow: guard participantId (and require `ready` for a custom-fn strategy) ->
round-scoped push (first-seen joinedAt, rounds[round] merge) -> makeReadiness
gate -> communicate(payload, isReady, timeout) (one .catch covers push and
timeout) -> assignRoles over the resolved snapshot (no TOCTOU) ->
setMyAssignment -> finishTrial; timeout/error -> handleTimeout fails loud and
always ends the trial.

Rewrite index.spec.ts with a mock API (in-memory session, reject-on-timeout
via fake timers): 7 wrapper tests plus the kept static-surface tests.
Update README status note and docs param/data tables + examples.

162 tests green, tsc clean, rollup build green (default-export convention intact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tead of masking as timeout

trial() now returns its promise and uses a two-arg .then so that a throw from
assignRoles (overflow with no overflow_role, bad role_from, throwing custom fn)
surfaces loudly as a config bug rather than being relabelled as a readiness
timeout. Only communicate()'s own rejection routes to the soft timeout path.
Also fire on_load() after rendering, since jsPsych does not auto-fire it for
promise-returning trials. Add tests for the custom-strategy spectator case and
config-error propagation; clarify assigned_self docs (overflow is in the map).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
htsukamoto5 and others added 3 commits June 29, 2026 15:30
…ustness

Address pre-review findings (the High one is a real consensus hazard):
- HIGH: replace locale-dependent `localeCompare` tie-breaks (join_order and
  rank_by) with a code-unit comparator `byId`, matching the code-unit base
  sort. `String.prototype.localeCompare` uses the runtime's locale/ICU
  collation, so two clients in different locales could order tied ids
  differently and compute divergent role maps — the exact property the plugin
  exists to guarantee. `byId` is now the single ordering rule (also used for
  readiness ctx.ids).
- Warn when group_size and a custom ready are both omitted: readiness can
  otherwise resolve over a partial group the instant this client pushes.
- handleTimeout wraps the on_timeout hook in try/catch so a throwing hook no
  longer skips finishTrial (which would reintroduce the hang the timeout
  prevents).
- Friendly error when `roles` is missing/empty instead of a cryptic TypeError.
- Docs: ctx.seed caveat, overflow_role applies whenever count exceeds slots,
  corrected rotate-formula sign, strengthened membership-consensus caveat.
  Fix repository.directory / homepage.
- Tests: code-unit tie-break (mixed-case ids), roles validation, role_from
  precedence over rank_by/strategy, partial-group warning, throwing
  on_timeout still finishes. 170 tests green.

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

Match the official jsPsych plugin convention:
- jspsych moves from a hard dependency to a peerDependency (>=8.0.0) plus a
  devDependency, so the published plugin uses the host's jsPsych instead of
  bundling its own (avoids a duplicate-instance ParameterType/instanceof
  mismatch).
- @citation-js/* are removed from dependencies — citation generation is a
  build-time concern handled by @jspsych/config, not a runtime dep. Official
  plugins that use the citations feature list no @citation-js deps either.

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

The generated examples/index.html instantiated the plugin with no roles and no
connected adapter, so it threw on load. Replace it with an illustrative example
showing real usage — roles/strategy/group_size and getMyRole() for downstream
timeline branching — with a clear note that it requires a connected multiplayer
adapter and cannot run standalone until jsPsych#3694 lands (the runnable
end-to-end demo is the forthcoming ultimatum-game example).

Co-Authored-By: Claude Opus 4.8 <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