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 as HintMap)
: 'world'
// Get hint for current region (if available)
const hintText = useRegionHint(hintMapKey, currentPrompt)
// Get hint for current region (if available) - with cycling support
const {
currentHint: hintText,
hintIndex,
totalHints,
nextHint,
hasMoreHints,
} = useRegionHint(hintMapKey, currentPrompt)
const hasHint = useHasRegionHint(hintMapKey, currentPrompt)
// Get the current region name for audio hints
@ -1024,14 +1030,14 @@ export function MapRenderer({
if (delay > 0) {
// Schedule delayed announcement
announcementTimeoutRef.current = setTimeout(() => {
speakWithRegionName(currentRegionName, null, false)
speakWithRegionName(currentRegionName, hintText, false)
announcementTimeoutRef.current = null
}, delay)
} else {
// 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
// - In learning mode: Triggered when puzzlePieceTarget is set (takeover fades back in)
@ -1123,6 +1129,65 @@ export function MapRenderer({
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
useEffect(() => {
setControlsState({
@ -4202,11 +4267,7 @@ export function MapRenderer({
}}
>
{/* North indicator - red triangle pointing up */}
<polygon
points="16,1 14,5 18,5"
fill="#ef4444"
opacity={0.9}
/>
<polygon points="16,1 14,5 18,5" fill="#ef4444" opacity={0.9} />
</animated.g>
</animated.svg>
</div>
@ -4336,7 +4397,13 @@ export function MapRenderer({
)
})}
{/* 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 */}
<animated.g
style={{
@ -4345,11 +4412,7 @@ export function MapRenderer({
}}
>
{/* North indicator - red triangle pointing up */}
<polygon
points="20,2 17.5,7 22.5,7"
fill="#ef4444"
opacity={0.9}
/>
<polygon points="20,2 17.5,7 22.5,7" fill="#ef4444" opacity={0.9} />
</animated.g>
</animated.svg>
</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 { getHints, hasHints, type HintMap } from '../messages'
import type { Locale } from '@/i18n/messages'
/**
* Hook to get a randomly selected hint for a region in the current locale.
* The hint is selected once when the region changes and stays consistent
* for that region prompt. Different hints may appear on subsequent attempts
* at the same region.
* Hook to get hints for a region with cycling support.
* Starts with a random hint, and can cycle to the next hint when user is struggling.
*
* 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
// Track which region we last selected a hint for
// Track which region we're showing hints for
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
const hint = useMemo(() => {
if (!regionId) {
lastRegionRef.current = null
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
// Get all hints for this region
const allHints = useMemo(() => {
if (!regionId) return []
return getHints(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
}
/**