All files / web/src/components/toys/coordinate-plane/challenge useChallenge.ts

0% Statements 0/150
0% Branches 0/1
0% Functions 0/1
0% Lines 0/150

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151                                                                                                                                                                                                                                                                                                             
import { useRef, useState, useCallback, useEffect } from 'react'
import type { ChallengeState, ChallengePhase } from './types'
import type { WordProblem, DifficultyLevel } from '../wordProblems/types'
import type { RulerState } from '../ruler/types'
import type { CoordinatePlaneState } from '../types'
import { generateWordProblem } from '../wordProblems/generate'
import { checkAnswer } from './answerCheck'

const PRESENT_DELAY_MS = 300
const CELEBRATE_DURATION_MS = 600
const REVEAL_STEP_MS = 500

interface UseChallengeOptions {
  rulerRef: React.MutableRefObject<RulerState>
  stateRef: React.MutableRefObject<CoordinatePlaneState>
  rulerVersion: number
  enabled: boolean
  onComplete?: (problem: WordProblem, attempts: number) => void
  /** Called when a new problem is summoned, to trigger viewport animation */
  onSummon?: (problem: WordProblem) => void
}

export function useChallenge({
  rulerRef,
  stateRef,
  rulerVersion,
  enabled,
  onComplete,
  onSummon,
}: UseChallengeOptions) {
  const challengeRef = useRef<ChallengeState>({
    phase: 'idle',
    problem: null,
    attempts: 0,
    phaseStartTime: 0,
    revealStep: 0,
  })
  const [challengeVersion, setChallengeVersion] = useState(0)

  const updatePhase = useCallback((phase: ChallengePhase) => {
    challengeRef.current.phase = phase
    challengeRef.current.phaseStartTime = performance.now()
    setChallengeVersion((v) => v + 1)
  }, [])

  /** Summon a new problem */
  const summonProblem = useCallback(
    (difficulty: DifficultyLevel = 3) => {
      const seed = Math.floor(Math.random() * 0xffffffff)
      const problem = generateWordProblem(seed, difficulty)
      challengeRef.current.problem = problem
      challengeRef.current.attempts = 0
      challengeRef.current.revealStep = 0

      // Start with auto-adjusting phase (viewport animation)
      updatePhase('auto-adjusting')
      onSummon?.(problem)

      // After a brief delay for viewport animation, transition to presenting
      setTimeout(() => {
        if (challengeRef.current.phase === 'auto-adjusting') {
          updatePhase('presenting')
          // After slide-in animation, transition to solving
          setTimeout(() => {
            if (challengeRef.current.phase === 'presenting') {
              updatePhase('solving')
            }
          }, PRESENT_DELAY_MS)
        }
      }, 600) // viewport animation duration
    },
    [enabled, updatePhase, onSummon]
  )

  /** Dismiss the current problem */
  const dismissProblem = useCallback(() => {
    updatePhase('idle')
    challengeRef.current.problem = null
  }, [updatePhase])

  /** Check the answer when ruler changes during solving phase */
  useEffect(() => {
    if (challengeRef.current.phase !== 'solving') return
    if (!challengeRef.current.problem) return

    const ruler = rulerRef.current
    const problem = challengeRef.current.problem
    const { correct } = checkAnswer(ruler.ax, ruler.ay, ruler.bx, ruler.by, problem)

    if (correct) {
      challengeRef.current.attempts += 1
      updatePhase('answering')
    }
  }, [rulerVersion, updatePhase]) // eslint-disable-line react-hooks/exhaustive-deps

  /** Step through reveal annotations one at a time */
  const startRevealSequence = useCallback(() => {
    const problem = challengeRef.current.problem
    if (!problem) return

    const annotatedTags = problem.spans.filter(
      (s) => s.tag && s.tag !== 'context' && s.tag !== 'question'
    )
    const totalSteps = annotatedTags.length

    let step = 0
    const interval = setInterval(() => {
      step++
      challengeRef.current.revealStep = step
      setChallengeVersion((v) => v + 1)

      if (step >= totalSteps) {
        clearInterval(interval)
        updatePhase('revealed')
        onComplete?.(problem, challengeRef.current.attempts)
      }
    }, REVEAL_STEP_MS)

    return () => clearInterval(interval)
  }, [updatePhase, onComplete])

  /** Called when the kid enters the correct x answer — fires onComplete and summons next problem */
  const handleAnswerCorrect = useCallback(
    (difficulty: DifficultyLevel = 3) => {
      const problem = challengeRef.current.problem
      if (!problem) return

      onComplete?.(problem, challengeRef.current.attempts)

      // Brief celebration, then summon next problem
      updatePhase('celebrating')
      setTimeout(() => {
        if (challengeRef.current.phase === 'celebrating') {
          summonProblem(difficulty)
        }
      }, CELEBRATE_DURATION_MS)
    },
    [updatePhase, onComplete, summonProblem]
  )

  return {
    challengeRef,
    challengeVersion,
    summonProblem,
    dismissProblem,
    handleAnswerCorrect,
    phase: challengeRef.current.phase,
    problem: challengeRef.current.problem,
  }
}