feat(know-your-world): add adaptive hint cycling for struggling users

- Modify useRegionHint hook to support cycling through multiple hints
  - Returns { currentHint, hintIndex, totalHints, nextHint, hasMoreHints }
  - Starts with random hint, advances on nextHint() call
  - Resets cycle when region changes
  - Add useRegionHintSimple for backwards compatibility

- Add struggle detection in MapRenderer
  - Monitors time spent searching for region
  - Gives next hint every 30 seconds of struggle
  - Announces new hint via speech synthesis when it changes

- Fix Part 1 hint announcement regression
  - Was passing null instead of hintText to speakWithRegionName
  - Now correctly announces region name followed by hint text

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-01 17:47:59 -06:00
parent e0b762e3ee
commit 54402501e5
2 changed files with 163 additions and 54 deletions

View File

@ -794,8 +794,14 @@ export function MapRenderer({
: selectedContinent !== 'all' : selectedContinent !== 'all'
? (selectedContinent as HintMap) ? (selectedContinent as HintMap)
: 'world' : 'world'
// Get hint for current region (if available) // Get hint for current region (if available) - with cycling support
const hintText = useRegionHint(hintMapKey, currentPrompt) const {
currentHint: hintText,
hintIndex,
totalHints,
nextHint,
hasMoreHints,
} = useRegionHint(hintMapKey, currentPrompt)
const hasHint = useHasRegionHint(hintMapKey, currentPrompt) const hasHint = useHasRegionHint(hintMapKey, currentPrompt)
// Get the current region name for audio hints // Get the current region name for audio hints
@ -1024,14 +1030,14 @@ export function MapRenderer({
if (delay > 0) { if (delay > 0) {
// Schedule delayed announcement // Schedule delayed announcement
announcementTimeoutRef.current = setTimeout(() => { announcementTimeoutRef.current = setTimeout(() => {
speakWithRegionName(currentRegionName, null, false) speakWithRegionName(currentRegionName, hintText, false)
announcementTimeoutRef.current = null announcementTimeoutRef.current = null
}, delay) }, delay)
} else { } else {
// No recent "You found", announce immediately // No recent "You found", announce immediately
speakWithRegionName(currentRegionName, null, false) speakWithRegionName(currentRegionName, hintText, false)
} }
}, [currentPrompt, currentRegionName, isSpeechSupported, speakWithRegionName]) }, [currentPrompt, currentRegionName, hintText, isSpeechSupported, speakWithRegionName])
// Part 2: Announce "You found {region}" at the START of part 2 // Part 2: Announce "You found {region}" at the START of part 2
// - In learning mode: Triggered when puzzlePieceTarget is set (takeover fades back in) // - In learning mode: Triggered when puzzlePieceTarget is set (takeover fades back in)
@ -1123,6 +1129,65 @@ export function MapRenderer({
resetHotCold() resetHotCold()
}, [currentPrompt, resetHotCold]) }, [currentPrompt, resetHotCold])
// Struggle detection: Give additional hints when user is having trouble
// Thresholds for triggering next hint (must have more hints available)
const STRUGGLE_TIME_THRESHOLD = 30000 // 30 seconds per hint level
const STRUGGLE_CHECK_INTERVAL = 5000 // Check every 5 seconds
const lastHintLevelRef = useRef(0) // Track which hint level triggered last hint
useEffect(() => {
// Only run if hot/cold is enabled (means we're tracking search metrics)
if (!effectiveHotColdEnabled || !hasMoreHints || !currentPrompt) return
const checkStruggle = () => {
const metrics = getSearchMetrics(promptStartTime.current)
// Calculate which hint level we should be at based on time
// Level 0 = first 30 seconds, Level 1 = 30-60 seconds, etc.
const expectedHintLevel = Math.floor(metrics.timeToFind / STRUGGLE_TIME_THRESHOLD)
// If we should be at a higher hint level than last triggered, give next hint
if (expectedHintLevel > lastHintLevelRef.current && hasMoreHints) {
lastHintLevelRef.current = expectedHintLevel
nextHint()
}
}
const intervalId = setInterval(checkStruggle, STRUGGLE_CHECK_INTERVAL)
return () => clearInterval(intervalId)
}, [
effectiveHotColdEnabled,
hasMoreHints,
currentPrompt,
getSearchMetrics,
nextHint,
promptStartTime,
])
// Reset hint level tracking when prompt changes
useEffect(() => {
lastHintLevelRef.current = 0
}, [currentPrompt])
// Speak new hint when it changes (due to cycling)
const prevHintIndexRef = useRef(hintIndex)
useEffect(() => {
// Only speak if hint index actually changed (not initial render)
if (
hintIndex > prevHintIndexRef.current &&
hintText &&
isSpeechSupported &&
currentRegionName
) {
prevHintIndexRef.current = hintIndex
// Speak just the hint (user already knows the region name)
speak(hintText, false)
} else if (hintIndex !== prevHintIndexRef.current) {
prevHintIndexRef.current = hintIndex
}
}, [hintIndex, hintText, isSpeechSupported, currentRegionName, speak])
// Update context with controls state for GameInfoPanel // Update context with controls state for GameInfoPanel
useEffect(() => { useEffect(() => {
setControlsState({ setControlsState({
@ -4202,11 +4267,7 @@ export function MapRenderer({
}} }}
> >
{/* North indicator - red triangle pointing up */} {/* North indicator - red triangle pointing up */}
<polygon <polygon points="16,1 14,5 18,5" fill="#ef4444" opacity={0.9} />
points="16,1 14,5 18,5"
fill="#ef4444"
opacity={0.9}
/>
</animated.g> </animated.g>
</animated.svg> </animated.svg>
</div> </div>
@ -4336,7 +4397,13 @@ export function MapRenderer({
) )
})} })}
{/* Center dot */} {/* Center dot */}
<circle cx="20" cy="20" r="1.5" fill={heatStyle.color} opacity={heatStyle.opacity} /> <circle
cx="20"
cy="20"
r="1.5"
fill={heatStyle.color}
opacity={heatStyle.opacity}
/>
{/* Counter-rotating group to keep N fixed pointing up */} {/* Counter-rotating group to keep N fixed pointing up */}
<animated.g <animated.g
style={{ style={{
@ -4345,11 +4412,7 @@ export function MapRenderer({
}} }}
> >
{/* North indicator - red triangle pointing up */} {/* North indicator - red triangle pointing up */}
<polygon <polygon points="20,2 17.5,7 22.5,7" fill="#ef4444" opacity={0.9} />
points="20,2 17.5,7 22.5,7"
fill="#ef4444"
opacity={0.9}
/>
</animated.g> </animated.g>
</animated.svg> </animated.svg>
</div> </div>

View File

@ -1,53 +1,99 @@
import { useMemo, useRef } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { useLocale } from 'next-intl' import { useLocale } from 'next-intl'
import { getHints, hasHints, type HintMap } from '../messages' import { getHints, hasHints, type HintMap } from '../messages'
import type { Locale } from '@/i18n/messages' import type { Locale } from '@/i18n/messages'
/** /**
* Hook to get a randomly selected hint for a region in the current locale. * Hook to get hints for a region with cycling support.
* The hint is selected once when the region changes and stays consistent * Starts with a random hint, and can cycle to the next hint when user is struggling.
* for that region prompt. Different hints may appear on subsequent attempts
* at the same region.
* *
* Returns null if no hints exist for the region. * Returns:
* - currentHint: The current hint text (null if no hints)
* - hintIndex: Current index (0-based)
* - totalHints: Total number of hints available
* - nextHint: Function to advance to the next hint (cycles)
* - hasMoreHints: Whether there are hints the user hasn't seen yet
*/ */
export function useRegionHint(map: HintMap, regionId: string | null): string | null { export interface UseRegionHintResult {
currentHint: string | null
hintIndex: number
totalHints: number
nextHint: () => void
hasMoreHints: boolean
}
export function useRegionHint(map: HintMap, regionId: string | null): UseRegionHintResult {
const locale = useLocale() as Locale const locale = useLocale() as Locale
// Track which region we last selected a hint for // Track which region we're showing hints for
const lastRegionRef = useRef<string | null>(null) const lastRegionRef = useRef<string | null>(null)
const selectedHintRef = useRef<string | null>(null) // Track the starting index (randomized on region change)
const startIndexRef = useRef<number>(0)
// Track how many hints we've cycled through for this region
const [cycleOffset, setCycleOffset] = useState(0)
// When region changes, select a new random hint // Get all hints for this region
const hint = useMemo(() => { const allHints = useMemo(() => {
if (!regionId) { if (!regionId) return []
lastRegionRef.current = null return getHints(locale, map, regionId) ?? []
selectedHintRef.current = null
return null
}
const hints = getHints(locale, map, regionId)
if (!hints || hints.length === 0) {
lastRegionRef.current = regionId
selectedHintRef.current = null
return null
}
// If same region, return previously selected hint
if (regionId === lastRegionRef.current && selectedHintRef.current !== null) {
return selectedHintRef.current
}
// New region - select a random hint
const randomIndex = Math.floor(Math.random() * hints.length)
const selected = hints[randomIndex]
lastRegionRef.current = regionId
selectedHintRef.current = selected
return selected
}, [locale, map, regionId]) }, [locale, map, regionId])
return hint // Reset cycle offset when region changes
if (regionId !== lastRegionRef.current) {
lastRegionRef.current = regionId
// Pick a random starting index for this region
startIndexRef.current = allHints.length > 0 ? Math.floor(Math.random() * allHints.length) : 0
// Reset cycle offset (can't call setState in render, so we check in useMemo below)
}
// Calculate current hint
const result = useMemo(() => {
// Reset cycle offset if region changed (detected by mismatch)
const effectiveCycleOffset = regionId === lastRegionRef.current ? cycleOffset : 0
if (allHints.length === 0) {
return {
currentHint: null,
hintIndex: 0,
totalHints: 0,
hasMoreHints: false,
}
}
const currentIndex = (startIndexRef.current + effectiveCycleOffset) % allHints.length
return {
currentHint: allHints[currentIndex],
hintIndex: effectiveCycleOffset,
totalHints: allHints.length,
hasMoreHints: effectiveCycleOffset < allHints.length - 1,
}
}, [allHints, cycleOffset, regionId])
// Reset cycle offset when region changes
const prevRegionRef = useRef<string | null>(null)
if (regionId !== prevRegionRef.current) {
prevRegionRef.current = regionId
if (cycleOffset !== 0) {
setCycleOffset(0)
}
}
// Function to advance to the next hint
const nextHint = useCallback(() => {
if (allHints.length > 1) {
setCycleOffset((prev) => prev + 1)
}
}, [allHints.length])
return {
...result,
nextHint,
}
}
// Legacy function signature for backwards compatibility
export function useRegionHintSimple(map: HintMap, regionId: string | null): string | null {
const { currentHint } = useRegionHint(map, regionId)
return currentHint
} }
/** /**