feat(know-your-world): add Learning mode and fix hints before name unlock

- Add new "Learning" assistance level (📚) that requires typing first 3 letters
  before hints are shown - best for memorizing region names
- Remove 3-letter requirement from "Guided" mode (now just has auto-hints)
- Fix bug where auto-hints bypassed the name confirmation requirement
- Add hintsLocked prop to MapRenderer to properly suppress hints
- Share hint unlock state between GameInfoPanel and MapRenderer via PlayingPhase

The Learning mode is now the easiest option, designed specifically for
learning region names by requiring active recall before showing hints.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-27 16:21:12 -06:00
parent a17e951e7c
commit fc87808b40
9 changed files with 153 additions and 36 deletions

View File

@ -38,7 +38,12 @@ interface KnowYourWorldContextValue {
// Cursor position sharing (for multiplayer) // Cursor position sharing (for multiplayer)
otherPlayerCursors: Record< otherPlayerCursors: Record<
string, string,
{ x: number; y: number; userId: string; hoveredRegionId: string | null } | null {
x: number
y: number
userId: string
hoveredRegionId: string | null
} | null
> >
sendCursorUpdate: ( sendCursorUpdate: (
playerId: string, playerId: string,
@ -98,7 +103,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
: ['huge', 'large', 'medium'] // Default to most regions : ['huge', 'large', 'medium'] // Default to most regions
// Validate assistanceLevel // Validate assistanceLevel
const validAssistanceLevels = ['guided', 'helpful', 'standard', 'none'] const validAssistanceLevels = ['learning', 'guided', 'helpful', 'standard', 'none']
const rawAssistance = gameConfig?.assistanceLevel const rawAssistance = gameConfig?.assistanceLevel
const assistanceLevel: AssistanceLevel = const assistanceLevel: AssistanceLevel =
typeof rawAssistance === 'string' && validAssistanceLevels.includes(rawAssistance) typeof rawAssistance === 'string' && validAssistanceLevels.includes(rawAssistance)

View File

@ -127,7 +127,10 @@ export class KnowYourWorldValidator
data: any data: any
): ValidationResult { ): ValidationResult {
if (state.gamePhase !== 'playing') { if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only click regions during playing phase' } return {
valid: false,
error: 'Can only click regions during playing phase',
}
} }
if (!state.currentPrompt) { if (!state.currentPrompt) {
@ -351,7 +354,10 @@ export class KnowYourWorldValidator
includeSizes: RegionSize[] includeSizes: RegionSize[]
): ValidationResult { ): ValidationResult {
if (state.gamePhase !== 'setup') { if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change region sizes during setup' } return {
valid: false,
error: 'Can only change region sizes during setup',
}
} }
const newState: KnowYourWorldState = { const newState: KnowYourWorldState = {
@ -367,7 +373,10 @@ export class KnowYourWorldValidator
assistanceLevel: AssistanceLevel assistanceLevel: AssistanceLevel
): ValidationResult { ): ValidationResult {
if (state.gamePhase !== 'setup') { if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change assistance level during setup' } return {
valid: false,
error: 'Can only change assistance level during setup',
}
} }
const newState: KnowYourWorldState = { const newState: KnowYourWorldState = {
@ -448,7 +457,10 @@ export class KnowYourWorldValidator
// Check if this session has already voted // Check if this session has already voted
const existingVotes = state.giveUpVotes ?? [] const existingVotes = state.giveUpVotes ?? []
if (existingVotes.includes(userId)) { if (existingVotes.includes(userId)) {
return { valid: false, error: 'Your session has already voted to give up' } return {
valid: false,
error: 'Your session has already voted to give up',
}
} }
// Add this session's vote // Add this session's vote
@ -498,10 +510,12 @@ export class KnowYourWorldValidator
: [...existingGivenUp, region.id] : [...existingGivenUp, region.id]
// Determine re-ask position based on assistance level // Determine re-ask position based on assistance level
// Guided/Helpful: re-ask soon (after 2-3 regions) to reinforce learning // Learning/Guided/Helpful: re-ask soon (after 2-3 regions) to reinforce learning
// Standard/None: re-ask at the end // Standard/None: re-ask at the end
const isHighAssistance = const isHighAssistance =
state.assistanceLevel === 'guided' || state.assistanceLevel === 'helpful' state.assistanceLevel === 'learning' ||
state.assistanceLevel === 'guided' ||
state.assistanceLevel === 'helpful'
const reaskDelay = isHighAssistance ? 3 : state.regionsToFind.length const reaskDelay = isHighAssistance ? 3 : state.regionsToFind.length
// Build new regions queue: take next regions, then insert given-up region at appropriate position // Build new regions queue: take next regions, then insert given-up region at appropriate position
@ -548,7 +562,10 @@ export class KnowYourWorldValidator
timestamp: number timestamp: number
): ValidationResult { ): ValidationResult {
if (state.gamePhase !== 'playing') { if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only request hints during playing phase' } return {
valid: false,
error: 'Can only request hints during playing phase',
}
} }
if (!state.currentPrompt) { if (!state.currentPrompt) {
@ -588,7 +605,13 @@ export class KnowYourWorldValidator
: ['huge', 'large', 'medium'] // Default : ['huge', 'large', 'medium'] // Default
// Validate assistanceLevel // Validate assistanceLevel
const validAssistanceLevels: AssistanceLevel[] = ['guided', 'helpful', 'standard', 'none'] const validAssistanceLevels: AssistanceLevel[] = [
'learning',
'guided',
'helpful',
'standard',
'none',
]
const rawAssistance = typedConfig?.assistanceLevel const rawAssistance = typedConfig?.assistanceLevel
const assistanceLevel: AssistanceLevel = validAssistanceLevels.includes( const assistanceLevel: AssistanceLevel = validAssistanceLevels.includes(
rawAssistance as AssistanceLevel rawAssistance as AssistanceLevel

View File

@ -26,6 +26,8 @@ interface GameInfoPanelProps {
foundCount: number foundCount: number
totalRegions: number totalRegions: number
progress: number progress: number
/** Callback when hints are unlocked (after name confirmation) */
onHintsUnlock?: () => void
} }
export function GameInfoPanel({ export function GameInfoPanel({
@ -36,6 +38,7 @@ export function GameInfoPanel({
foundCount, foundCount,
totalRegions, totalRegions,
progress, progress,
onHintsUnlock,
}: GameInfoPanelProps) { }: GameInfoPanelProps) {
// Get flag emoji for world map countries (not USA states) // Get flag emoji for world map countries (not USA states)
const flagEmoji = const flagEmoji =
@ -117,11 +120,12 @@ export function GameInfoPanel({
if (requiresNameConfirmation > 0 && currentRegionName && nameInput.length > 0) { if (requiresNameConfirmation > 0 && currentRegionName && nameInput.length > 0) {
const requiredPart = currentRegionName.slice(0, requiresNameConfirmation).toLowerCase() const requiredPart = currentRegionName.slice(0, requiresNameConfirmation).toLowerCase()
const inputPart = nameInput.toLowerCase() const inputPart = nameInput.toLowerCase()
if (inputPart === requiredPart) { if (inputPart === requiredPart && !nameConfirmed) {
setNameConfirmed(true) setNameConfirmed(true)
onHintsUnlock?.()
} }
} }
}, [nameInput, currentRegionName, requiresNameConfirmation]) }, [nameInput, currentRegionName, requiresNameConfirmation, nameConfirmed, onHintsUnlock])
// Determine if hints are available based on difficulty config // Determine if hints are available based on difficulty config
const hintsAvailable = useMemo(() => { const hintsAvailable = useMemo(() => {

View File

@ -202,7 +202,7 @@ interface MapRendererProps {
mapData: MapData mapData: MapData
regionsFound: string[] regionsFound: string[]
currentPrompt: string | null currentPrompt: string | null
assistanceLevel: 'guided' | 'helpful' | 'standard' | 'none' // Controls gameplay features (hints, hot/cold) assistanceLevel: 'learning' | 'guided' | 'helpful' | 'standard' | 'none' // Controls gameplay features (hints, hot/cold)
selectedMap: 'world' | 'usa' // Map ID for calculating excluded regions selectedMap: 'world' | 'usa' // Map ID for calculating excluded regions
selectedContinent: string // Continent ID for calculating excluded regions selectedContinent: string // Continent ID for calculating excluded regions
onRegionClick: (regionId: string, regionName: string) => void onRegionClick: (regionId: string, regionName: string) => void
@ -251,7 +251,12 @@ interface MapRendererProps {
localPlayerId?: string // The local player's ID (to filter out our own cursor from others) localPlayerId?: string // The local player's ID (to filter out our own cursor from others)
otherPlayerCursors?: Record< otherPlayerCursors?: Record<
string, string,
{ x: number; y: number; userId: string; hoveredRegionId: string | null } | null {
x: number
y: number
userId: string
hoveredRegionId: string | null
} | null
> >
onCursorUpdate?: ( onCursorUpdate?: (
cursorPosition: { x: number; y: number } | null, cursorPosition: { x: number; y: number } | null,
@ -263,6 +268,8 @@ interface MapRendererProps {
viewerId?: string // This viewer's userId (to check if local session has voted) viewerId?: string // This viewer's userId (to check if local session has voted)
// Member players mapping (userId -> players) for cursor emoji display // Member players mapping (userId -> players) for cursor emoji display
memberPlayers?: Record<string, Array<{ id: string; name: string; emoji: string; color: string }>> memberPlayers?: Record<string, Array<{ id: string; name: string; emoji: string; color: string }>>
/** When true, hints are locked (e.g., user hasn't typed required name confirmation yet) */
hintsLocked?: boolean
} }
/** /**
@ -328,6 +335,7 @@ export function MapRenderer({
activeUserIds = [], activeUserIds = [],
viewerId, viewerId,
memberPlayers = {}, memberPlayers = {},
hintsLocked = false,
}: MapRendererProps) { }: MapRendererProps) {
// Extract force tuning parameters with defaults // Extract force tuning parameters with defaults
const { const {
@ -440,8 +448,14 @@ export function MapRenderer({
initialZoom: 10, initialZoom: 10,
}) })
const [svgDimensions, setSvgDimensions] = useState({ width: 1000, height: 500 }) const [svgDimensions, setSvgDimensions] = useState({
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null) width: 1000,
height: 500,
})
const [cursorPosition, setCursorPosition] = useState<{
x: number
y: number
} | null>(null)
const [showMagnifier, setShowMagnifier] = useState(false) const [showMagnifier, setShowMagnifier] = useState(false)
const [targetOpacity, setTargetOpacity] = useState(0) const [targetOpacity, setTargetOpacity] = useState(0)
const [targetTop, setTargetTop] = useState(20) const [targetTop, setTargetTop] = useState(20)
@ -754,12 +768,13 @@ export function MapRenderer({
hotColdEnabledRef.current = effectiveHotColdEnabled hotColdEnabledRef.current = effectiveHotColdEnabled
// Handle hint bubble and auto-speak when the prompt changes (new region to find) // Handle hint bubble and auto-speak when the prompt changes (new region to find)
// Only runs when currentPrompt changes, not when settings change // Also re-runs when hintsLocked changes (e.g., user unlocked hints by typing name)
useEffect(() => { useEffect(() => {
const isNewRegion = prevPromptRef.current !== null && prevPromptRef.current !== currentPrompt const isNewRegion = prevPromptRef.current !== null && prevPromptRef.current !== currentPrompt
prevPromptRef.current = currentPrompt prevPromptRef.current = currentPrompt
if (autoHintRef.current && hasHint) { // Don't auto-show hints when locked (e.g., waiting for name confirmation)
if (autoHintRef.current && hasHint && !hintsLocked) {
setShowHintBubble(true) setShowHintBubble(true)
// If region changed and both auto-hint and auto-speak are enabled, speak immediately // If region changed and both auto-hint and auto-speak are enabled, speak immediately
// This handles the case where the bubble was already open // This handles the case where the bubble was already open
@ -769,7 +784,15 @@ export function MapRenderer({
} else { } else {
setShowHintBubble(false) setShowHintBubble(false)
} }
}, [currentPrompt, hasHint, currentRegionName, hintText, isSpeechSupported, speakWithRegionName]) }, [
currentPrompt,
hasHint,
currentRegionName,
hintText,
isSpeechSupported,
speakWithRegionName,
hintsLocked,
])
// Hot/cold audio feedback hook // Hot/cold audio feedback hook
// Only enabled if: 1) assistance level allows it, 2) user toggle is on, 3) not touch device // Only enabled if: 1) assistance level allows it, 2) user toggle is on, 3) not touch device
@ -865,7 +888,10 @@ export function MapRenderer({
if (pointerLocked && cursorPositionRef.current && containerRef.current && svgRef.current) { if (pointerLocked && cursorPositionRef.current && containerRef.current && svgRef.current) {
const { x: cursorX, y: cursorY } = cursorPositionRef.current const { x: cursorX, y: cursorY } = cursorPositionRef.current
console.log('[CLICK] Pointer lock click at cursor position:', { cursorX, cursorY }) console.log('[CLICK] Pointer lock click at cursor position:', {
cursorX,
cursorY,
})
// Check if clicking on any registered button (Give Up, Hint, etc.) // Check if clicking on any registered button (Give Up, Hint, etc.)
if (buttonRegistry.handleClick(cursorX, cursorY)) { if (buttonRegistry.handleClick(cursorX, cursorY)) {
@ -1127,7 +1153,11 @@ export function MapRenderer({
}) })
// Start zoom-in animation using CSS transform // Start zoom-in animation using CSS transform
console.log('[GiveUp Zoom] Setting zoom target:', { scale, translateX, translateY }) console.log('[GiveUp Zoom] Setting zoom target:', {
scale,
translateX,
translateY,
})
setGiveUpZoomTarget({ scale, translateX, translateY }) setGiveUpZoomTarget({ scale, translateX, translateY })
} }
} }
@ -1666,8 +1696,13 @@ export function MapRenderer({
const viewBoxHeight = viewBoxParts[3] || 1000 const viewBoxHeight = viewBoxParts[3] || 1000
const showOutline = (region: MapRegion): boolean => { const showOutline = (region: MapRegion): boolean => {
// Guided/Helpful modes: always show outlines // Learning/Guided/Helpful modes: always show outlines
if (assistanceLevel === 'guided' || assistanceLevel === 'helpful') return true if (
assistanceLevel === 'learning' ||
assistanceLevel === 'guided' ||
assistanceLevel === 'helpful'
)
return true
// Standard/None modes: only show outline on hover or if found // Standard/None modes: only show outline on hover or if found
return hoveredRegion === region.id || regionsFound.includes(region.id) return hoveredRegion === region.id || regionsFound.includes(region.id)
@ -1763,7 +1798,10 @@ export function MapRenderer({
width: containerRect.width.toFixed(1), width: containerRect.width.toFixed(1),
height: containerRect.height.toFixed(1), height: containerRect.height.toFixed(1),
}, },
svgSize: { width: svgRect.width.toFixed(1), height: svgRect.height.toFixed(1) }, svgSize: {
width: svgRect.width.toFixed(1),
height: svgRect.height.toFixed(1),
},
svgOffset: { x: svgOffsetX.toFixed(1), y: svgOffsetY.toFixed(1) }, svgOffset: { x: svgOffsetX.toFixed(1), y: svgOffsetY.toFixed(1) },
distances: { distances: {
left: dampenedDistLeft.toFixed(1), left: dampenedDistLeft.toFixed(1),
@ -3722,7 +3760,10 @@ export function MapRenderer({
const magTL = { x: magLeft, y: magTop } const magTL = { x: magLeft, y: magTop }
const magTR = { x: magLeft + magnifierWidth, y: magTop } const magTR = { x: magLeft + magnifierWidth, y: magTop }
const magBL = { x: magLeft, y: magTop + magnifierHeight } const magBL = { x: magLeft, y: magTop + magnifierHeight }
const magBR = { x: magLeft + magnifierWidth, y: magTop + magnifierHeight } const magBR = {
x: magLeft + magnifierWidth,
y: magTop + magnifierHeight,
}
// Check if a line segment passes through a rectangle (excluding endpoints) // Check if a line segment passes through a rectangle (excluding endpoints)
const linePassesThroughRect = ( const linePassesThroughRect = (
@ -3963,7 +4004,11 @@ export function MapRenderer({
{zoomSearchDebugInfo && ( {zoomSearchDebugInfo && (
<> <>
<div <div
style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid #444' }} style={{
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid #444',
}}
> >
<strong>Zoom Decision:</strong> <strong>Zoom Decision:</strong>
</div> </div>
@ -4011,7 +4056,13 @@ export function MapRenderer({
</> </>
)} )}
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid #444' }}> <div
style={{
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid #444',
}}
>
<strong>Detected Regions ({detectedRegions.length}):</strong> <strong>Detected Regions ({detectedRegions.length}):</strong>
</div> </div>
{detectedRegions.map((region) => ( {detectedRegions.map((region) => (

View File

@ -1,10 +1,10 @@
'use client' 'use client'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo, useState, useEffect } from 'react'
import { css } from '@styled/css' import { css } from '@styled/css'
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels' import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
import { useKnowYourWorld } from '../Provider' import { useKnowYourWorld } from '../Provider'
import { getFilteredMapDataBySizesSync } from '../maps' import { getFilteredMapDataBySizesSync, getAssistanceLevel } from '../maps'
import { MapRenderer } from './MapRenderer' import { MapRenderer } from './MapRenderer'
import { GameInfoPanel } from './GameInfoPanel' import { GameInfoPanel } from './GameInfoPanel'
import { useViewerId } from '@/lib/arcade/game-sdk' import { useViewerId } from '@/lib/arcade/game-sdk'
@ -55,6 +55,26 @@ export function PlayingPhase() {
const currentRegionName = currentRegion?.name ?? null const currentRegionName = currentRegion?.name ?? null
const currentRegionId = currentRegion?.id ?? null const currentRegionId = currentRegion?.id ?? null
// Check if hints are locked (name confirmation required but not yet done)
const assistanceConfig = getAssistanceLevel(state.assistanceLevel)
const requiresNameConfirmation = assistanceConfig.nameConfirmationLetters ?? 0
// Track whether hints have been unlocked for the current region
const [hintsUnlocked, setHintsUnlocked] = useState(false)
// Reset hints locked state when region changes
useEffect(() => {
setHintsUnlocked(false)
}, [state.currentPrompt])
// Hints are locked if name confirmation is required and not yet unlocked
const hintsLocked = requiresNameConfirmation > 0 && !hintsUnlocked
// Callback for GameInfoPanel to notify when hints are unlocked
const handleHintsUnlock = useCallback(() => {
setHintsUnlocked(true)
}, [])
// Error if prompt not found in filtered regions (indicates server/client filter mismatch) // Error if prompt not found in filtered regions (indicates server/client filter mismatch)
if (state.currentPrompt && !currentRegion) { if (state.currentPrompt && !currentRegion) {
const errorInfo = { const errorInfo = {
@ -102,6 +122,7 @@ export function PlayingPhase() {
foundCount={foundCount} foundCount={foundCount}
totalRegions={totalRegions} totalRegions={totalRegions}
progress={progress} progress={progress}
onHintsUnlock={handleHintsUnlock}
/> />
</Panel> </Panel>
@ -164,6 +185,7 @@ export function PlayingPhase() {
activeUserIds={state.activeUserIds} activeUserIds={state.activeUserIds}
viewerId={viewerId ?? undefined} viewerId={viewerId ?? undefined}
memberPlayers={memberPlayers} memberPlayers={memberPlayers}
hintsLocked={hintsLocked}
/> />
</div> </div>
</Panel> </Panel>

View File

@ -369,7 +369,7 @@ export function SetupPhase() {
<Select.Root <Select.Root
value={state.assistanceLevel} value={state.assistanceLevel}
onValueChange={(value) => onValueChange={(value) =>
setAssistanceLevel(value as 'guided' | 'helpful' | 'standard' | 'none') setAssistanceLevel(value as 'learning' | 'guided' | 'helpful' | 'standard' | 'none')
} }
> >
<Select.Trigger className={cardTriggerStyles}> <Select.Trigger className={cardTriggerStyles}>

View File

@ -48,7 +48,7 @@ function validateKnowYourWorldConfig(config: unknown): config is KnowYourWorldCo
] ]
const validSizes = ['huge', 'large', 'medium', 'small', 'tiny'] const validSizes = ['huge', 'large', 'medium', 'small', 'tiny']
const validAssistanceLevels = ['guided', 'helpful', 'standard', 'none'] const validAssistanceLevels = ['learning', 'guided', 'helpful', 'standard', 'none']
return ( return (
typeof config === 'object' && typeof config === 'object' &&

View File

@ -163,11 +163,11 @@ export interface AssistanceLevelConfig {
*/ */
export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [ export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
{ {
id: 'guided', id: 'learning',
label: 'Guided', label: 'Learning',
emoji: '🎓', emoji: '📚',
description: description:
'Maximum help - type name to unlock hints, hot/cold feedback, shows names on wrong clicks', 'Type first 3 letters to unlock hints, maximum feedback, best for memorizing names',
hotColdEnabled: true, hotColdEnabled: true,
hintsMode: 'onRequest', hintsMode: 'onRequest',
autoHintDefault: true, autoHintDefault: true,
@ -176,6 +176,18 @@ export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
wrongClickShowsName: true, wrongClickShowsName: true,
nameConfirmationLetters: 3, // Must type first 3 letters to unlock hints nameConfirmationLetters: 3, // Must type first 3 letters to unlock hints
}, },
{
id: 'guided',
label: 'Guided',
emoji: '🎓',
description: 'Maximum help - auto hints, hot/cold feedback, shows names on wrong clicks',
hotColdEnabled: true,
hintsMode: 'onRequest',
autoHintDefault: true,
struggleHintEnabled: true,
giveUpMode: 'reaskSoon',
wrongClickShowsName: true,
},
{ {
id: 'helpful', id: 'helpful',
label: 'Helpful', label: 'Helpful',

View File

@ -6,7 +6,7 @@ import type { MapDifficultyConfig, RegionSize } from './maps'
* Assistance level - controls gameplay features (hints, hot/cold, etc.) * Assistance level - controls gameplay features (hints, hot/cold, etc.)
* Separate from region filtering * Separate from region filtering
*/ */
export type AssistanceLevel = 'guided' | 'helpful' | 'standard' | 'none' export type AssistanceLevel = 'learning' | 'guided' | 'helpful' | 'standard' | 'none'
// Game configuration (persisted to database) // Game configuration (persisted to database)
export interface KnowYourWorldConfig extends GameConfig { export interface KnowYourWorldConfig extends GameConfig {