From fc87808b40578054f0196a5d68ab35f56d375585 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 27 Nov 2025 16:21:12 -0600 Subject: [PATCH] feat(know-your-world): add Learning mode and fix hints before name unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../arcade-games/know-your-world/Provider.tsx | 9 ++- .../arcade-games/know-your-world/Validator.ts | 39 +++++++-- .../components/GameInfoPanel.tsx | 8 +- .../components/MapRenderer.tsx | 81 +++++++++++++++---- .../components/PlayingPhase.tsx | 26 +++++- .../know-your-world/components/SetupPhase.tsx | 2 +- .../src/arcade-games/know-your-world/index.ts | 2 +- .../src/arcade-games/know-your-world/maps.ts | 20 ++++- .../src/arcade-games/know-your-world/types.ts | 2 +- 9 files changed, 153 insertions(+), 36 deletions(-) diff --git a/apps/web/src/arcade-games/know-your-world/Provider.tsx b/apps/web/src/arcade-games/know-your-world/Provider.tsx index 24bf82dc..f5a587f5 100644 --- a/apps/web/src/arcade-games/know-your-world/Provider.tsx +++ b/apps/web/src/arcade-games/know-your-world/Provider.tsx @@ -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) diff --git a/apps/web/src/arcade-games/know-your-world/Validator.ts b/apps/web/src/arcade-games/know-your-world/Validator.ts index bb84a1ea..bb025961 100644 --- a/apps/web/src/arcade-games/know-your-world/Validator.ts +++ b/apps/web/src/arcade-games/know-your-world/Validator.ts @@ -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 diff --git a/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx b/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx index f93a1932..e24c9ee8 100644 --- a/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx @@ -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(() => { diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index cfe43382..ff27f426 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -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> + /** 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 && ( <>
Zoom Decision:
@@ -4011,7 +4056,13 @@ export function MapRenderer({ )} -
+
Detected Regions ({detectedRegions.length}):
{detectedRegions.map((region) => ( diff --git a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx index 4e1c1ea3..e9f0d513 100644 --- a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx @@ -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} /> @@ -164,6 +185,7 @@ export function PlayingPhase() { activeUserIds={state.activeUserIds} viewerId={viewerId ?? undefined} memberPlayers={memberPlayers} + hintsLocked={hintsLocked} />
diff --git a/apps/web/src/arcade-games/know-your-world/components/SetupPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/SetupPhase.tsx index ba54005b..6d588020 100644 --- a/apps/web/src/arcade-games/know-your-world/components/SetupPhase.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/SetupPhase.tsx @@ -369,7 +369,7 @@ export function SetupPhase() { - setAssistanceLevel(value as 'guided' | 'helpful' | 'standard' | 'none') + setAssistanceLevel(value as 'learning' | 'guided' | 'helpful' | 'standard' | 'none') } > diff --git a/apps/web/src/arcade-games/know-your-world/index.ts b/apps/web/src/arcade-games/know-your-world/index.ts index 021f2e4f..fdddf144 100644 --- a/apps/web/src/arcade-games/know-your-world/index.ts +++ b/apps/web/src/arcade-games/know-your-world/index.ts @@ -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' && diff --git a/apps/web/src/arcade-games/know-your-world/maps.ts b/apps/web/src/arcade-games/know-your-world/maps.ts index c3b222b7..8c328c6c 100644 --- a/apps/web/src/arcade-games/know-your-world/maps.ts +++ b/apps/web/src/arcade-games/know-your-world/maps.ts @@ -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', diff --git a/apps/web/src/arcade-games/know-your-world/types.ts b/apps/web/src/arcade-games/know-your-world/types.ts index ff8461be..bb1fa86e 100644 --- a/apps/web/src/arcade-games/know-your-world/types.ts +++ b/apps/web/src/arcade-games/know-your-world/types.ts @@ -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 {