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

View File

@ -127,7 +127,10 @@ export class KnowYourWorldValidator
data: any
): ValidationResult {
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) {
@ -351,7 +354,10 @@ export class KnowYourWorldValidator
includeSizes: RegionSize[]
): ValidationResult {
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 = {
@ -367,7 +373,10 @@ export class KnowYourWorldValidator
assistanceLevel: AssistanceLevel
): ValidationResult {
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 = {
@ -448,7 +457,10 @@ export class KnowYourWorldValidator
// Check if this session has already voted
const existingVotes = state.giveUpVotes ?? []
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
@ -498,10 +510,12 @@ export class KnowYourWorldValidator
: [...existingGivenUp, region.id]
// 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
const isHighAssistance =
state.assistanceLevel === 'guided' || state.assistanceLevel === 'helpful'
state.assistanceLevel === 'learning' ||
state.assistanceLevel === 'guided' ||
state.assistanceLevel === 'helpful'
const reaskDelay = isHighAssistance ? 3 : state.regionsToFind.length
// Build new regions queue: take next regions, then insert given-up region at appropriate position
@ -548,7 +562,10 @@ export class KnowYourWorldValidator
timestamp: number
): ValidationResult {
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) {
@ -588,7 +605,13 @@ export class KnowYourWorldValidator
: ['huge', 'large', 'medium'] // Default
// Validate assistanceLevel
const validAssistanceLevels: AssistanceLevel[] = ['guided', 'helpful', 'standard', 'none']
const validAssistanceLevels: AssistanceLevel[] = [
'learning',
'guided',
'helpful',
'standard',
'none',
]
const rawAssistance = typedConfig?.assistanceLevel
const assistanceLevel: AssistanceLevel = validAssistanceLevels.includes(
rawAssistance as AssistanceLevel

View File

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

View File

@ -202,7 +202,7 @@ interface MapRendererProps {
mapData: MapData
regionsFound: string[]
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
selectedContinent: string // Continent ID for calculating excluded regions
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)
otherPlayerCursors?: Record<
string,
{ x: number; y: number; userId: string; hoveredRegionId: string | null } | null
{
x: number
y: number
userId: string
hoveredRegionId: string | null
} | null
>
onCursorUpdate?: (
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)
// Member players mapping (userId -> players) for cursor emoji display
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 = [],
viewerId,
memberPlayers = {},
hintsLocked = false,
}: MapRendererProps) {
// Extract force tuning parameters with defaults
const {
@ -440,8 +448,14 @@ export function MapRenderer({
initialZoom: 10,
})
const [svgDimensions, setSvgDimensions] = useState({ width: 1000, height: 500 })
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null)
const [svgDimensions, setSvgDimensions] = useState({
width: 1000,
height: 500,
})
const [cursorPosition, setCursorPosition] = useState<{
x: number
y: number
} | null>(null)
const [showMagnifier, setShowMagnifier] = useState(false)
const [targetOpacity, setTargetOpacity] = useState(0)
const [targetTop, setTargetTop] = useState(20)
@ -754,12 +768,13 @@ export function MapRenderer({
hotColdEnabledRef.current = effectiveHotColdEnabled
// 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(() => {
const isNewRegion = prevPromptRef.current !== null && 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)
// If region changed and both auto-hint and auto-speak are enabled, speak immediately
// This handles the case where the bubble was already open
@ -769,7 +784,15 @@ export function MapRenderer({
} else {
setShowHintBubble(false)
}
}, [currentPrompt, hasHint, currentRegionName, hintText, isSpeechSupported, speakWithRegionName])
}, [
currentPrompt,
hasHint,
currentRegionName,
hintText,
isSpeechSupported,
speakWithRegionName,
hintsLocked,
])
// Hot/cold audio feedback hook
// 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) {
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.)
if (buttonRegistry.handleClick(cursorX, cursorY)) {
@ -1127,7 +1153,11 @@ export function MapRenderer({
})
// 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 })
}
}
@ -1666,8 +1696,13 @@ export function MapRenderer({
const viewBoxHeight = viewBoxParts[3] || 1000
const showOutline = (region: MapRegion): boolean => {
// Guided/Helpful modes: always show outlines
if (assistanceLevel === 'guided' || assistanceLevel === 'helpful') return true
// Learning/Guided/Helpful modes: always show outlines
if (
assistanceLevel === 'learning' ||
assistanceLevel === 'guided' ||
assistanceLevel === 'helpful'
)
return true
// Standard/None modes: only show outline on hover or if found
return hoveredRegion === region.id || regionsFound.includes(region.id)
@ -1763,7 +1798,10 @@ export function MapRenderer({
width: containerRect.width.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) },
distances: {
left: dampenedDistLeft.toFixed(1),
@ -3722,7 +3760,10 @@ export function MapRenderer({
const magTL = { x: magLeft, y: magTop }
const magTR = { x: magLeft + magnifierWidth, y: magTop }
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)
const linePassesThroughRect = (
@ -3963,7 +4004,11 @@ export function MapRenderer({
{zoomSearchDebugInfo && (
<>
<div
style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid #444' }}
style={{
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid #444',
}}
>
<strong>Zoom Decision:</strong>
</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>
</div>
{detectedRegions.map((region) => (

View File

@ -1,10 +1,10 @@
'use client'
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useState, useEffect } from 'react'
import { css } from '@styled/css'
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
import { useKnowYourWorld } from '../Provider'
import { getFilteredMapDataBySizesSync } from '../maps'
import { getFilteredMapDataBySizesSync, getAssistanceLevel } from '../maps'
import { MapRenderer } from './MapRenderer'
import { GameInfoPanel } from './GameInfoPanel'
import { useViewerId } from '@/lib/arcade/game-sdk'
@ -55,6 +55,26 @@ export function PlayingPhase() {
const currentRegionName = currentRegion?.name ?? 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)
if (state.currentPrompt && !currentRegion) {
const errorInfo = {
@ -102,6 +122,7 @@ export function PlayingPhase() {
foundCount={foundCount}
totalRegions={totalRegions}
progress={progress}
onHintsUnlock={handleHintsUnlock}
/>
</Panel>
@ -164,6 +185,7 @@ export function PlayingPhase() {
activeUserIds={state.activeUserIds}
viewerId={viewerId ?? undefined}
memberPlayers={memberPlayers}
hintsLocked={hintsLocked}
/>
</div>
</Panel>

View File

@ -369,7 +369,7 @@ export function SetupPhase() {
<Select.Root
value={state.assistanceLevel}
onValueChange={(value) =>
setAssistanceLevel(value as 'guided' | 'helpful' | 'standard' | 'none')
setAssistanceLevel(value as 'learning' | 'guided' | 'helpful' | 'standard' | 'none')
}
>
<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 validAssistanceLevels = ['guided', 'helpful', 'standard', 'none']
const validAssistanceLevels = ['learning', 'guided', 'helpful', 'standard', 'none']
return (
typeof config === 'object' &&

View File

@ -163,11 +163,11 @@ export interface AssistanceLevelConfig {
*/
export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
{
id: 'guided',
label: 'Guided',
emoji: '🎓',
id: 'learning',
label: 'Learning',
emoji: '📚',
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,
hintsMode: 'onRequest',
autoHintDefault: true,
@ -176,6 +176,18 @@ export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
wrongClickShowsName: true,
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',
label: 'Helpful',

View File

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