Skip to content

feat: emit currency events for lessons and puzzles - #262

Merged
karthikeya1976 merged 2 commits into
feature/currency-ledger-infrafrom
feature/currency-event-emits
Oct 1, 2026
Merged

karthikeya1976 merged 2 commits into
feature/currency-ledger-infrafrom
feature/currency-event-emits

Conversation

@JimmyWu7

@JimmyWu7 JimmyWu7 commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the remaining currency event producer work for lesson and puzzle completions. This PR builds on the currency ledger infrastructure in #250 and wires server-side completion events into the existing currencyEvents service.

Type of Change

  • New feature
  • Bug fix
  • Refactor
  • Documentation update
  • Style/UI update
  • Performance improvement
  • Other (please specify):

Key Changes

  • Lessons: Emits lesson.completed only when /updateLessonCompletion makes genuine forward progress.
  • Puzzles: Adds POST /puzzles/solved for authenticated puzzle completions and emits puzzle.solved.
  • Event IDs: Uses deterministic event IDs so repeated requests cannot create duplicate currency events for the same user/action.
  • Tests: Added real-database integration coverage for lesson and puzzle currency-event emission, including successful completions and invalid/guest requests.

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing performed
  • All tests pass

Verified with:

  • lessons.currencyEmit.test.js — passing
  • puzzles.currencyEmit.test.js — passing
  • Combined result: 2 test suites, 8 tests passing

Bugs Fixed (if applicable)

  • N/A

TODO (Follow-up Work)

  • Confirm the new /puzzles/solved endpoint against the frontend puzzle-solving flow during integration testing.

Additional Notes

@JimmyWu7
JimmyWu7 force-pushed the feature/currency-event-emits branch from 47278b8 to ac15801 Compare October 1, 2026 21:09

@karthikeya1976 karthikeya1976 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran the full suite for real (330/330, matches the claim) and traced both the lessons and puzzles changes against the actual code. The lesson.completed side is untouched and still correctly server-verified — nice catch replacing the fixed setImmediate tick with a proper waitForActionEvent poll in that test file, that's a real robustness improvement over what I originally wrote.

Please fix before merge

POST /puzzles/solved has no server-side proof the puzzle was actually solved.

The handler only checks that puzzleId exists in the catalog and that the caller is authenticated — then awards currency unconditionally:

const puzzle = await puzzles.findOne({ puzzleId });
if (!puzzle) { return res.status(404)... }
const result = await emitPuzzleSolved({ userId: req.user._id, puzzleId });

There's no comparison against puzzle.moves (which the model already stores — confirmed via the test fixture's own seedPuzzle using FEN/moves), no session-tracked move history, nothing. As written, any authenticated user can loop over every puzzle ID in the catalog and farm currency for puzzles they never attempted, let alone solved. The test suite actually demonstrates this directly — the "authenticated puzzle completion" test posts a bare { puzzleId } with zero solve evidence and asserts success; there's no test for "puzzle exists but wasn't actually solved" because the endpoint has no way to express that distinction.

This matters because it's the same trust boundary this whole currency rollout has been deliberately strict about everywhere else: lesson.completed only fires after a real DB-verified modifiedCount > 0, badges' check-and-award recomputes stats server-side and explicitly rejects client-supplied predicates, and PUT /user/updateHighScore was removed earlier in this same effort specifically because it let a client assert its own progress. This endpoint currently reintroduces that exact pattern for currency instead of badges.

The frontend already has what's needed to fix this correctly: Puzzles.tsx's moveListRef walks the server-supplied puzzle.Moves sequence move-by-move and only declares "puzzle completed" once the list is empty (Puzzles.tsx:326). The server-side fix is the same shape — have the client submit its played move sequence (or whatever is already tracked server-side per session) and verify it against puzzle.moves before emitting, rather than trusting a bare puzzleId.

Given there's no frontend caller yet (confirmed — nothing in react-ystemandchess/src calls /puzzles/solved), this is the right time to fix it, before an integration locks in "just POST the id" as the contract.

Everything else checks out

  • lessons.js: unchanged, still correctly gated.
  • Deterministic eventId (puzzle:<userId>:<puzzleId>) — correct idempotency pattern, consistent with emitLessonCompleted.
  • Guest/missing-puzzleId/nonexistent-puzzle rejection paths are all correctly tested and don't emit.
  • Full suite: 330/330, matches the PR's claim exactly.

Once the completion is actually verified server-side (even a minimal check against puzzle.moves), happy to re-review.

🤖 Generated with Claude Code

Addresses review feedback on this PR: POST /puzzles/solved previously
awarded currency based solely on an authenticated caller POSTing a
puzzleId that existed in the catalog, with no proof the puzzle was
actually solved. Any authenticated user could loop over every puzzleId
in the catalog and farm currency for puzzles never attempted.

- routes/puzzles.js: the route now requires a `moves` array (the UCI
  move sequence the player submitted) and verifies it positionally
  against the puzzle's stored `moves` answer key before emitting
  puzzle.solved. moveMatches() mirrors Puzzles.tsx's own
  handlePlayerMove leniency exactly (a 4-char move is accepted against
  a 5-char/promotion solution) so a move the client UI already treated
  as correct can't be rejected server-side as a mismatch.
- Puzzles.tsx: adds playedMovesRef to accumulate the player's verified
  moves as they're confirmed correct (moveListRef shrinks via .shift()
  as the puzzle progresses, so it's empty by completion and can't be
  used for this). Wires a new reportPuzzleSolved() call at the existing
  "puzzle completed" point, fire-and-forget so a network hiccup doesn't
  block the success-modal UX. Skipped entirely for guests, matching the
  backend's own "no account to credit" reasoning.
- Tests: added coverage for the move-mismatch rejection (the actual
  regression case), partial/missing/empty moves, and both directions of
  the promotion leniency (4-char submission against a 5-char solution
  accepted; wrong promotion piece at the same length rejected).

Noted limitation, not fully closed by this fix: puzzle.moves (the
answer key) is already sent to the client to drive the interactive
play-through, so a client that reads it directly and submits it
verbatim without playing will still pass. This raises the bar from
"needs nothing" to "needs the real solution string" — closing the
remaining gap would need either not shipping the full solution to the
client up front, or a different completion-proof mechanism entirely;
flagging as further follow-up rather than silently claiming this is a
complete fix.

336/336 backend tests passing (330 baseline + 6 new), TypeScript
compiles clean on the frontend change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@karthikeya1976

Copy link
Copy Markdown
Collaborator

Pushed a commit addressing the review feedback above directly, since this blocks the broader currency rollout and the fix was well-scoped.

What changed: POST /puzzles/solved now requires a moves array (the UCI sequence the player submitted) and verifies it positionally against the puzzle's stored moves answer key before awarding currency — a bare puzzleId is no longer sufficient. Puzzles.tsx now tracks the player's verified moves as they're confirmed correct (separate from moveListRef, which shrinks to empty by the time the puzzle completes) and submits them via a new reportPuzzleSolved() call at the existing completion point.

Worth being upfront about: this isn't a complete defense. puzzle.moves (the answer key) is already sent to the client to drive the interactive play-through, so a client that reads it directly and replays it verbatim without actually solving anything would still pass verification. What this does close is the cheaper, more likely gap the original version had wide open — looping over every puzzleId with zero solve data and getting credited regardless. Fully closing the remaining gap would mean either not shipping the full solution to the client up front or a different completion-proof mechanism — noted as further follow-up, not solved here.

336/336 backend tests pass (330 baseline + 6 new, covering the mismatch-rejection case and both directions of the promotion leniency Puzzles.tsx already has), TypeScript compiles clean on the frontend change. Re-reviewing now that this is in.

🤖 Generated with Claude Code

@karthikeya1976 karthikeya1976 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The move-verification fix is now in (pushed directly since it blocked the broader rollout and was well-scoped — see the comment above for details). Re-verified independently:

  • Ran the full middlewareNode suite myself: 336/336 (330 baseline + 6 new covering the mismatch-rejection case and both directions of the promotion leniency).
  • npx tsc --noEmit on the frontend change: clean.
  • Confirmed moveMatches() genuinely mirrors Puzzles.tsx's own handlePlayerMove leniency rule exactly (a 4-char submission matches a 5-char/promotion solution; a wrong promotion piece at the same length is correctly rejected) rather than inventing new leniency that could drift from what the client actually accepts.
  • Note: CI doesn't run on this branch at all — ci.yml's pull_request trigger is scoped to branches: ["main"] only, and this PR targets feature/currency-ledger-infra. Not a blocker, just explains why no checks show up here; the full suite was run directly instead.

The one remaining limitation (a client that reads puzzle.moves directly and replays it without solving would still pass) is called out explicitly in the fix's commit message and PR comment rather than left implicit — that's a real, harder problem (the answer key is necessarily sent to the client for the interactive UI) and reasonable to track as separate follow-up rather than block this PR on.

Approving.

🤖 Generated with Claude Code

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.

2 participants