Bugfix/multiplayer scoring and duplicates #2

Merged
TehRiehlDeal merged 11 commits from bugfix/multiplayer-scoring-and-duplicates into main 2026-09-09 22:06:30 -07:00
Owner
No description provided.
TehRiehlDeal added 11 commits 2026-09-09 22:03:19 -07:00
Versus, Game Night and Async Versus could never complete a match.
startGameFromVersus was a copy of startGame minus the session-creation
block, so sessionId stayed null. score is only ever assigned inside the
`if (sessionId)` branch of the completion handler, and every multiplayer
submission is gated on score being non-null - so the submit never fired,
the opponent was never told, and the match never resolved. The completion
modals had no dismiss path, making it an unrecoverable dead end.

- startGameFromVersus now creates the session and reports the outcome as
  'started' | 'superseded' | 'failed'. The middle case matters: React
  StrictMode remounts the effect and its cleanup resets the store, which
  must not be reported to the player as an error.
- Completion moves into finalizeGame, which awaits an in-flight session
  rather than racing it (a fast player could close the loop first) and
  always resolves to a score or a visible error, never a silent null.
- A generation counter retires work belonging to abandoned games, so a
  slow session-creation response can no longer attach itself to the
  next game.
- Achievements are no longer stored on `score`. They were posted back to
  the API, where forbidNonWhitelisted rejected the submission with a 400.
- A failed async submit is no longer retried on every render; it
  previously fired ~20 identical failing requests.
- The versus and game-night completion modals gained a way out so a
  stalled opponent is not a trap.

Verified end to end: an async match played to completion submits, and
Postgres records the score against both the match and the session.
Mirrors the server-side change: reusing an actor or movie was rejected,
but project-plan.md section 4 calls for a points penalty instead, so that
a player is never stranded. Reuse is now accepted with a non-blocking
warning that says it costs points, and the penalty shows up in the score
breakdown at completion.

Also selects store actions individually rather than destructuring the
whole store, which was re-rendering every consumer on each keystroke.
react-hooks/set-state-in-effect and react-hooks/purity had been demoted
to warnings "so CI passes", and CI ran without --max-warnings 0, so all
22 violations were invisible there. Both rules are back at error.

- Confetti is rolled once per mount instead of during render, where it
  re-rolled on every re-render and made pieces visibly jump.
- Loading flags are derived from a "last settled request" key rather than
  set synchronously inside an effect. That also adds the cancellation
  these fetches were missing, so a resolved request can no longer write
  into a unmounted or superseded view.
- Effect dependency arrays corrected; the two eslint-disable-line
  comments in Profile.tsx are gone, and the split of its two effects
  removes the guard that silently never re-ran.
- GameCompletionModal derives visibility from status instead of mirroring
  it into state, with a ref so the sound and localStorage write fire once.
- ValidationFeedback adjusts state during render, the documented pattern,
  instead of paying an extra committed render per link.
- The three react-refresh suppressions in ui/ are resolved properly by
  extracting the variants into their own modules, so the component files
  export only components. badgeVariants had no consumers and is no longer
  exported.
- Drops a stale eslint-disable directive in sw.js.

The lint script now fails on warnings so this cannot quietly regress.
Two separate reasons the containerised dev workflow did not work:

- CMD ran `npm run dev -- --host`. With no TTY under `docker compose up
  -d`, npm printed its banner and exited 0 immediately, so the dev server
  never started. Invoking vite directly keeps it running.
- Vite's watcher receives no file-change events through a Docker Desktop
  bind mount, so it served stale modules indefinitely: edits appeared to
  have no effect and HMR never fired. Polling is opt-in via
  VITE_USE_POLLING, set by the compose file, so native dev is unaffected.
Adds the changelog this repo was missing. Git tags currently run ahead of
package.json (v1.1.1 exists while package.json was 1.0.1), so the tag line
and the package version still need reconciling separately - no tag is
created here.
Supersedes the 1.0.x release commits on this branch. Those were held back
on the grounds that a v1.1.0 git tag already existed - which was wrong: the
three local tags (v1.0.0, v1.1.0, v1.1.1) were never pushed, and the remote
has no tags at all, so nothing was actually taken.

Minor is the correct bump. Requests that previously failed now succeed -
duplicate chain links are accepted, and validation responses carry a
per-field errors[] array - which is backward-compatible new behaviour
rather than a fix alone.

Both repos are set to 1.1.0 so the pair stays aligned. Still no tag; the
stale local-only tags are worth deleting to avoid confusing `git describe`.
The server no longer accepts a score, a chain or a hint count from the
client, so the callers change to match.

- completeGameSession posts no payload; the server scores the chain it
  validated and the hints it recorded.
- Hints go through POST /games/:id/hint, which is what makes the hint
  penalty enforceable - it was previously counted in the browser and
  self-reported at completion, so it was effectively optional. The server
  also picks the actors, replacing a client-side shuffle that used
  sort(() => Math.random() - 0.5), which is neither uniform nor a valid
  comparator.
- Versus, game-night and async submissions send the session id instead of
  a ScoreBreakdown. Sending a score object is what allowed any player to
  win any match by posting a large enough number.
- Stale hints are cleared when the chain changes; the previous movie's
  cast used to stay on screen after picking an actor another way.

Verified end to end: a custom game with one hint and one duplicate link
scores 2519, with the -200 hint penalty and -150 duplicate penalty both
applied from server-side state, and the obscurity bonus computed from real
TMDB popularity rather than anything the client claimed.
Minor: the completion, hint and match-submission contracts change shape, and
a new hint endpoint is added. Old clients are rejected loudly rather than
silently mis-scored.
`npm audit fix` cleared 23 vulnerabilities (14 high) entirely within
existing semver ranges - lockfile only, no package.json version changes.
`npm update` then pulled in-range updates including react 19.2 -> 19.3 and
@base-ui/react 1.2 -> 1.8.

shadcn, tailwindcss, @tailwindcss/vite and tw-animate-css move to
devDependencies. shadcn in particular is a scaffolding CLI with zero
runtime imports that was dragging msw, @modelcontextprotocol/sdk,
@babel/core and ts-morph into the production dependency tree - the tree
`npm ci --omit=dev` installs and the Trivy filesystem scan reads. The
production image is nginx serving dist/, so none of them ever shipped; they
were simply misclassified. The build stage installs all dependencies, so
the production build is unaffected.

The react-hooks plugin update (7.0.1 -> 7.1.1) caught five more
set-state-in-effect cases, all fixed rather than suppressed:

- SearchAutocomplete derives "query too short" and its loading/error state
  from which search has settled, instead of clearing state on every
  keystroke below the threshold; the mode-change reset moves to a
  render-phase adjustment, which also keeps focus that remounting via a
  `key` would have dropped mid-chain.
- Leaderboard uses the same derived request-key pattern as the other list
  pages, and gains the cancellation it was missing.
- Hint visibility is derived from the chain length the hints were fetched
  for, rather than cleared by an effect.
- The async submit guard becomes a ref: as state it was both the guard and
  a dependency, so setting it re-ran its own effect.

Verified in the browser: search, the mode reset, the Base UI dialog and a
full scored game all behave correctly, and the production image builds.
chore(release): 1.2.1
frontend-ci / lint (push) Successful in 17s
frontend-ci / typecheck (push) Successful in 15s
frontend-ci / secrets-scan (push) Successful in 5s
frontend-ci / sast (push) Successful in 13s
frontend-ci / fs-scan (push) Successful in 12s
frontend-ci / build (push) Successful in 36s
frontend-ci / push (push) Skipped
frontend-ci / typecheck (pull_request) Successful in 13s
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 6s
frontend-ci / fs-scan (pull_request) Successful in 11s
frontend-ci / lint (pull_request) Successful in 13s
frontend-ci / build (pull_request) Successful in 35s
frontend-ci / push (pull_request) Skipped
baeee32d3a
TehRiehlDeal merged commit 51dbc45608 into main 2026-09-09 22:06:30 -07:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: TehRiehlDeal/movieloop-frontend#2