feat(know-your-world): separate region filtering from assistance level

Split the "difficulty" setting into two distinct concepts:
- Region sizes: Checkbox selection for which regions to include (huge, large, medium, small, tiny)
- Assistance level: Dropdown for gameplay features (hints, hot/cold feedback, etc.)

Changes:
- Add region size checkboxes with per-category counts in SetupPhase
- Add assistance level dropdown with feature badges
- Update Validator to handle new move types (SET_REGION_SIZES, SET_ASSISTANCE_LEVEL)
- Show excluded regions as dimmed/grayed out on the setup map
- Update MapRenderer to use assistanceLevel instead of difficulty
- Update game-configs.ts with new default config fields

🤖 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-26 21:00:20 -06:00
parent a6f8dbe474
commit 9499e4e8b5
14 changed files with 1725 additions and 228 deletions

View File

@ -10,7 +10,8 @@ import {
useViewerId,
} from '@/lib/arcade/game-sdk'
import { buildPlayerOwnershipFromRoomData } from '@/lib/arcade/player-ownership.client'
import type { KnowYourWorldState, KnowYourWorldMove } from './types'
import type { KnowYourWorldState, AssistanceLevel } from './types'
import type { RegionSize } from './maps'
interface KnowYourWorldContextValue {
state: KnowYourWorldState
@ -24,13 +25,15 @@ interface KnowYourWorldContextValue {
nextRound: () => void
endGame: () => void
giveUp: () => void
requestHint: () => void
endStudy: () => void
returnToSetup: () => void
// Setup actions
setMap: (map: 'world' | 'usa') => void
setMode: (mode: 'cooperative' | 'race' | 'turn-based') => void
setDifficulty: (difficulty: string) => void
setRegionSizes: (sizes: RegionSize[]) => void
setAssistanceLevel: (level: AssistanceLevel) => void
setStudyDuration: (duration: 0 | 30 | 60 | 120) => void
setContinent: (continent: import('./continents').ContinentId | 'all') => void
@ -94,11 +97,27 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
? (rawContinent as any)
: 'all'
// Validate includeSizes - should be an array of valid size strings
const validSizes: RegionSize[] = ['huge', 'large', 'medium', 'small', 'tiny']
const rawSizes = gameConfig?.includeSizes
const includeSizes: RegionSize[] = Array.isArray(rawSizes)
? rawSizes.filter((s: string) => validSizes.includes(s as RegionSize))
: ['huge', 'large', 'medium'] // Default to most regions
// Validate assistanceLevel
const validAssistanceLevels = ['guided', 'helpful', 'standard', 'none']
const rawAssistance = gameConfig?.assistanceLevel
const assistanceLevel: AssistanceLevel =
typeof rawAssistance === 'string' && validAssistanceLevels.includes(rawAssistance)
? (rawAssistance as AssistanceLevel)
: 'helpful'
return {
gamePhase: 'setup' as const,
selectedMap: (gameConfig?.selectedMap as 'world' | 'usa') || 'world',
gameMode: (gameConfig?.gameMode as 'cooperative' | 'race' | 'turn-based') || 'cooperative',
difficulty: gameConfig?.difficulty || 'medium',
includeSizes,
assistanceLevel,
studyDuration,
selectedContinent,
studyTimeRemaining: 0,
@ -117,6 +136,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
playerMetadata: {},
giveUpReveal: null,
giveUpVotes: [],
hintsUsed: 0,
hintActive: null,
}
}, [roomData])
@ -170,7 +191,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
playerMetadata,
selectedMap: state.selectedMap,
gameMode: state.gameMode,
difficulty: state.difficulty,
includeSizes: state.includeSizes,
assistanceLevel: state.assistanceLevel,
},
})
}, [
@ -181,7 +203,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
sendMove,
state.selectedMap,
state.gameMode,
state.difficulty,
state.includeSizes,
state.assistanceLevel,
])
// Action: Click Region
@ -241,6 +264,16 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
})
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
// Action: Request Hint (highlight current region briefly)
const requestHint = useCallback(() => {
sendMove({
type: 'REQUEST_HINT',
playerId: state.currentPlayer || activePlayers[0] || '',
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
// Setup Action: Set Map
const setMap = useCallback(
(selectedMap: 'world' | 'usa') => {
@ -301,14 +334,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
[viewerId, sendMove, roomData, updateGameConfig, activePlayers]
)
// Setup Action: Set Difficulty
const setDifficulty = useCallback(
(difficulty: string) => {
// Setup Action: Set Region Sizes (which sizes to include)
const setRegionSizes = useCallback(
(includeSizes: RegionSize[]) => {
sendMove({
type: 'SET_DIFFICULTY',
playerId: activePlayers[0] || '', // Use first active player
type: 'SET_REGION_SIZES',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: { difficulty },
data: { includeSizes },
})
// Persist to database
@ -322,7 +355,37 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
...currentGameConfig,
'know-your-world': {
...currentConfig,
difficulty,
includeSizes,
},
},
})
}
},
[viewerId, sendMove, roomData, updateGameConfig, activePlayers]
)
// Setup Action: Set Assistance Level
const setAssistanceLevel = useCallback(
(assistanceLevel: AssistanceLevel) => {
sendMove({
type: 'SET_ASSISTANCE_LEVEL',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: { assistanceLevel },
})
// Persist to database
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentConfig = (currentGameConfig['know-your-world'] as Record<string, any>) || {}
updateGameConfig({
roomId: roomData.id,
gameConfig: {
...currentGameConfig,
'know-your-world': {
...currentConfig,
assistanceLevel,
},
},
})
@ -431,11 +494,13 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
nextRound,
endGame,
giveUp,
requestHint,
endStudy,
returnToSetup,
setMap,
setMode,
setDifficulty,
setRegionSizes,
setAssistanceLevel,
setStudyDuration,
setContinent,
otherPlayerCursors,

View File

@ -4,17 +4,19 @@ import type {
KnowYourWorldMove,
KnowYourWorldState,
GuessRecord,
AssistanceLevel,
} from './types'
import type { RegionSize } from './maps'
/**
* Lazy-load map functions to avoid importing ES modules at module init time
* This is critical for server-side usage where ES modules can't be required
*/
async function getFilteredMapDataLazy(
...args: Parameters<typeof import('./maps').getFilteredMapData>
async function getFilteredMapDataBySizesLazy(
...args: Parameters<typeof import('./maps').getFilteredMapDataBySizes>
) {
const { getFilteredMapData } = await import('./maps')
return getFilteredMapData(...args)
const { getFilteredMapDataBySizes } = await import('./maps')
return getFilteredMapDataBySizes(...args)
}
export class KnowYourWorldValidator
@ -41,14 +43,18 @@ export class KnowYourWorldValidator
return this.validateSetMap(state, move.data.selectedMap)
case 'SET_MODE':
return this.validateSetMode(state, move.data.gameMode)
case 'SET_DIFFICULTY':
return this.validateSetDifficulty(state, move.data.difficulty)
case 'SET_REGION_SIZES':
return this.validateSetRegionSizes(state, move.data.includeSizes)
case 'SET_ASSISTANCE_LEVEL':
return this.validateSetAssistanceLevel(state, move.data.assistanceLevel)
case 'SET_STUDY_DURATION':
return this.validateSetStudyDuration(state, move.data.studyDuration)
case 'SET_CONTINENT':
return this.validateSetContinent(state, move.data.selectedContinent)
case 'GIVE_UP':
return await this.validateGiveUp(state, move.playerId, move.userId)
case 'REQUEST_HINT':
return this.validateRequestHint(state, move.playerId, move.timestamp)
default:
return { valid: false, error: 'Unknown move type' }
}
@ -63,14 +69,19 @@ export class KnowYourWorldValidator
return { valid: false, error: 'Can only start from setup phase' }
}
const { activePlayers, playerMetadata, selectedMap, gameMode, difficulty } = data
const { activePlayers, playerMetadata, selectedMap, gameMode, includeSizes, assistanceLevel } =
data
if (!activePlayers || activePlayers.length === 0) {
return { valid: false, error: 'Need at least 1 player' }
}
// Get map data and shuffle regions (with continent and difficulty filters)
const mapData = await getFilteredMapDataLazy(selectedMap, state.selectedContinent, difficulty)
// Get map data and shuffle regions (with continent and size filters)
const mapData = await getFilteredMapDataBySizesLazy(
selectedMap,
state.selectedContinent,
includeSizes || state.includeSizes
)
const regionIds = mapData.regions.map((r) => r.id)
const shuffledRegions = this.shuffleArray([...regionIds])
@ -96,7 +107,8 @@ export class KnowYourWorldValidator
playerMetadata,
selectedMap,
gameMode,
difficulty,
includeSizes: includeSizes || state.includeSizes,
assistanceLevel: assistanceLevel || state.assistanceLevel,
studyTimeRemaining: shouldStudy ? state.studyDuration : 0,
studyStartTime: shouldStudy ? Date.now() : 0,
currentPrompt: shouldStudy ? null : shuffledRegions[0],
@ -110,6 +122,8 @@ export class KnowYourWorldValidator
startTime: Date.now(),
giveUpReveal: null,
giveUpVotes: [],
hintsUsed: 0,
hintActive: null,
}
return { valid: true, newState }
@ -179,6 +193,7 @@ export class KnowYourWorldValidator
endTime: Date.now(),
giveUpReveal: null,
giveUpVotes: [], // Clear votes when game ends
hintActive: null,
activeUserIds,
}
return { valid: true, newState }
@ -206,6 +221,7 @@ export class KnowYourWorldValidator
guessHistory,
giveUpReveal: null,
giveUpVotes: [], // Clear votes when moving to next region
hintActive: null, // Clear hint when moving to next region
activeUserIds,
}
@ -236,7 +252,9 @@ export class KnowYourWorldValidator
return {
valid: true,
newState,
error: `Incorrect! Try again. Looking for: ${state.currentPrompt}`,
// Error message includes clicked region name for client to format based on difficulty
// Format: "CLICKED:[regionName]" so client can parse and format appropriately
error: `CLICKED:${regionName}`,
}
}
}
@ -255,11 +273,11 @@ export class KnowYourWorldValidator
return { valid: false, error: 'Can only start next round from results' }
}
// Get map data and shuffle regions (with continent and difficulty filters)
const mapData = await getFilteredMapDataLazy(
// Get map data and shuffle regions (with continent and size filters)
const mapData = await getFilteredMapDataBySizesLazy(
state.selectedMap,
state.selectedContinent,
state.difficulty
state.includeSizes
)
const regionIds = mapData.regions.map((r) => r.id)
const shuffledRegions = this.shuffleArray([...regionIds])
@ -292,6 +310,8 @@ export class KnowYourWorldValidator
endTime: undefined,
giveUpReveal: null,
giveUpVotes: [],
hintsUsed: 0,
hintActive: null,
}
return { valid: true, newState }
@ -340,14 +360,33 @@ export class KnowYourWorldValidator
return { valid: true, newState }
}
private validateSetDifficulty(state: KnowYourWorldState, difficulty: string): ValidationResult {
private validateSetRegionSizes(
state: KnowYourWorldState,
includeSizes: RegionSize[]
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change difficulty during setup' }
return { valid: false, error: 'Can only change region sizes during setup' }
}
const newState: KnowYourWorldState = {
...state,
difficulty,
includeSizes,
}
return { valid: true, newState }
}
private validateSetAssistanceLevel(
state: KnowYourWorldState,
assistanceLevel: AssistanceLevel
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change assistance level during setup' }
}
const newState: KnowYourWorldState = {
...state,
assistanceLevel,
}
return { valid: true, newState }
@ -493,10 +532,10 @@ export class KnowYourWorldValidator
activeUserIds: string[]
): Promise<ValidationResult> {
// Get region info for the reveal
const mapData = await getFilteredMapDataLazy(
const mapData = await getFilteredMapDataBySizesLazy(
state.selectedMap,
state.selectedContinent,
state.difficulty
state.includeSizes
)
const region = mapData.regions.find((r) => r.id === state.currentPrompt)
@ -511,10 +550,11 @@ export class KnowYourWorldValidator
? existingGivenUp
: [...existingGivenUp, region.id]
// Determine re-ask position based on difficulty
// Easy: re-ask soon (after 2-3 regions)
// Hard: re-ask at the end
const reaskDelay = state.difficulty === 'easy' ? 3 : state.regionsToFind.length
// Determine re-ask position based on assistance level
// 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'
const reaskDelay = isHighAssistance ? 3 : state.regionsToFind.length
// Build new regions queue: take next regions, then insert given-up region at appropriate position
const remainingRegions = [...state.regionsToFind]
@ -547,12 +587,44 @@ export class KnowYourWorldValidator
timestamp: Date.now(),
},
giveUpVotes: [], // Clear votes after give up is executed
hintActive: null, // Clear hint when moving to next region
activeUserIds,
}
return { valid: true, newState }
}
private validateRequestHint(
state: KnowYourWorldState,
playerId: string,
timestamp: number
): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only request hints during playing phase' }
}
if (!state.currentPrompt) {
return { valid: false, error: 'No region to hint' }
}
// For turn-based: check if it's this player's turn
if (state.gameMode === 'turn-based' && state.currentPlayer !== playerId) {
return { valid: false, error: 'Not your turn' }
}
// Set hint active with current region
const newState: KnowYourWorldState = {
...state,
hintsUsed: (state.hintsUsed ?? 0) + 1,
hintActive: {
regionId: state.currentPrompt,
timestamp,
},
}
return { valid: true, newState }
}
isGameComplete(state: KnowYourWorldState): boolean {
return state.gamePhase === 'results'
}
@ -560,11 +632,28 @@ export class KnowYourWorldValidator
getInitialState(config: unknown): KnowYourWorldState {
const typedConfig = config as KnowYourWorldConfig
// Validate includeSizes - should be an array of valid size strings
const validSizes: RegionSize[] = ['huge', 'large', 'medium', 'small', 'tiny']
const rawSizes = typedConfig?.includeSizes
const includeSizes: RegionSize[] = Array.isArray(rawSizes)
? rawSizes.filter((s: string) => validSizes.includes(s as RegionSize))
: ['huge', 'large', 'medium'] // Default
// Validate assistanceLevel
const validAssistanceLevels: AssistanceLevel[] = ['guided', 'helpful', 'standard', 'none']
const rawAssistance = typedConfig?.assistanceLevel
const assistanceLevel: AssistanceLevel = validAssistanceLevels.includes(
rawAssistance as AssistanceLevel
)
? (rawAssistance as AssistanceLevel)
: 'helpful' // Default
return {
gamePhase: 'setup',
selectedMap: typedConfig?.selectedMap || 'world',
gameMode: typedConfig?.gameMode || 'cooperative',
difficulty: typedConfig?.difficulty || 'easy',
includeSizes,
assistanceLevel,
studyDuration: typedConfig?.studyDuration || 0,
selectedContinent: typedConfig?.selectedContinent || 'all',
studyTimeRemaining: 0,
@ -583,6 +672,8 @@ export class KnowYourWorldValidator
playerMetadata: {},
giveUpReveal: null,
giveUpVotes: [],
hintsUsed: 0,
hintActive: null,
}
}

View File

@ -14,7 +14,9 @@ import {
getSubMapsForContinent,
parseViewBox,
calculateFitCropViewBox,
getFilteredMapDataBySizesSync,
} from '../maps'
import type { RegionSize } from '../maps'
import {
CONTINENTS,
getContinentForCountry,
@ -30,17 +32,29 @@ import {
*/
export type SelectionPath = [] | [ContinentId] | [ContinentId, string]
/**
* Planet data type for joke placeholder
*/
interface PlanetData {
id: string
name: string
color: string
size: number
hasStripes?: boolean
hasRings?: boolean
}
/**
* Joke placeholder: Other planets for when viewing Earth (world map)
* Just for fun - these don't actually do anything
*/
const PLANETS = [
const PLANETS: PlanetData[] = [
{ id: 'mercury', name: 'Mercury', color: '#b0b0b0', size: 0.38 },
{ id: 'venus', name: 'Venus', color: '#e6c87a', size: 0.95 },
{ id: 'mars', name: 'Mars', color: '#c1440e', size: 0.53 },
{ id: 'jupiter', name: 'Jupiter', color: '#d8ca9d', size: 2.0, hasStripes: true },
{ id: 'saturn', name: 'Saturn', color: '#ead6b8', size: 1.7, hasRings: true },
] as const
]
interface DrillDownMapSelectorProps {
/** Callback when selection changes (map/continent for game start) */
@ -51,6 +65,8 @@ interface DrillDownMapSelectorProps {
selectedMap: 'world' | 'usa'
/** Current selected continent (for initial state sync) */
selectedContinent: ContinentId | 'all'
/** Region sizes to include (for showing excluded regions dimmed) */
includeSizes: RegionSize[]
}
interface BreadcrumbItem {
@ -65,6 +81,7 @@ export function DrillDownMapSelector({
onStartGame,
selectedMap,
selectedContinent,
includeSizes,
}: DrillDownMapSelectorProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -216,6 +233,40 @@ export function DrillDownMapSelector({
return groups
}, [currentLevel])
// Calculate excluded regions based on includeSizes
// These are regions that exist but are filtered out by size settings
const excludedRegions = useMemo(() => {
// Determine which map we're looking at
const mapId = currentLevel === 2 && path[1] ? 'usa' : 'world'
const continentId: ContinentId | 'all' =
currentLevel >= 1 && path[0] ? path[0] : selectedContinent
// Get all regions (unfiltered by size)
const allRegionsMapData = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
['huge', 'large', 'medium', 'small', 'tiny'] // All sizes
)
const allRegionIds = new Set(allRegionsMapData.regions.map((r) => r.id))
// Get filtered regions (based on current includeSizes)
const filteredMapData = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
includeSizes
)
const filteredRegionIds = new Set(filteredMapData.regions.map((r) => r.id))
// Excluded = all regions minus filtered regions
const excluded: string[] = []
for (const regionId of allRegionIds) {
if (!filteredRegionIds.has(regionId)) {
excluded.push(regionId)
}
}
return excluded
}, [currentLevel, path, selectedContinent, includeSizes])
// Compute the label to display for the hovered region
// Shows the next drill-down level name, not the individual region name
const hoveredLabel = useMemo(() => {
@ -562,6 +613,7 @@ export function DrillDownMapSelector({
currentLevel === 0 && selectedContinent !== 'all' ? selectedContinent : null
}
hoverableRegions={currentLevel === 1 ? highlightedRegions : undefined}
excludedRegions={excludedRegions}
/>
{/* Zoom Out Button - positioned inside map, upper right */}
@ -679,7 +731,8 @@ export function DrillDownMapSelector({
{peers.map((peer) => {
// Check if this is a planet (joke at world level)
const isPlanet = 'isPlanet' in peer && peer.isPlanet
const planetData = 'planetData' in peer ? peer.planetData : null
const planetData =
'planetData' in peer ? (peer.planetData as PlanetData | null) : null
// Calculate viewBox for this peer's continent (only for non-planets)
const peerContinentId = peer.path[0]

View File

@ -1,11 +1,11 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, useMemo } from 'react'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import { useKnowYourWorld } from '../Provider'
import type { MapData } from '../types'
import { getCountryFlagEmoji } from '../maps'
import { getCountryFlagEmoji, WORLD_MAP, USA_MAP, DEFAULT_DIFFICULTY_CONFIG } from '../maps'
// Animation duration in ms - must match MapRenderer
const GIVE_UP_ANIMATION_DURATION = 2000
@ -34,11 +34,62 @@ export function GameInfoPanel({
selectedMap === 'world' && currentRegionId ? getCountryFlagEmoji(currentRegionId) : ''
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { state, lastError, clearError, giveUp } = useKnowYourWorld()
const { state, lastError, clearError, giveUp, requestHint } = useKnowYourWorld()
// Get current difficulty level config
const currentDifficultyLevel = useMemo(() => {
const mapDiffConfig =
(selectedMap === 'world' ? WORLD_MAP : USA_MAP).difficultyConfig || DEFAULT_DIFFICULTY_CONFIG
return (
mapDiffConfig.levels.find((level) => level.id === state.difficulty) || mapDiffConfig.levels[0]
)
}, [selectedMap, state.difficulty])
// Parse error message and format based on difficulty config
const formattedError = useMemo(() => {
if (!lastError) return null
// Check for "CLICKED:" prefix which indicates a wrong click
if (lastError.startsWith('CLICKED:')) {
const regionName = lastError.slice('CLICKED:'.length)
if (currentDifficultyLevel?.wrongClickShowsName) {
return `That was ${regionName}`
}
return null // Just show "Wrong!" without region name
}
// Other errors pass through as-is
return lastError
}, [lastError, currentDifficultyLevel])
// Track if animation is in progress (local state based on timestamp)
const [isAnimating, setIsAnimating] = useState(false)
// Determine if hints are available based on difficulty config
const hintsAvailable = useMemo(() => {
const hintsMode = currentDifficultyLevel?.hintsMode
if (hintsMode === 'none') return false
if (hintsMode === 'limited') {
const limit = currentDifficultyLevel?.hintLimit ?? 0
return (state.hintsUsed ?? 0) < limit
}
return hintsMode === 'onRequest'
}, [currentDifficultyLevel, state.hintsUsed])
// Calculate remaining hints for limited mode
const remainingHints = useMemo(() => {
if (currentDifficultyLevel?.hintsMode !== 'limited') return null
const limit = currentDifficultyLevel?.hintLimit ?? 0
return Math.max(0, limit - (state.hintsUsed ?? 0))
}, [currentDifficultyLevel, state.hintsUsed])
// Handle hint request
const handleHint = useCallback(() => {
if (hintsAvailable && state.gamePhase === 'playing' && !isAnimating) {
requestHint()
}
}, [hintsAvailable, state.gamePhase, isAnimating, requestHint])
// Check if animation is in progress based on timestamp
useEffect(() => {
if (!state.giveUpReveal?.timestamp) {
@ -188,6 +239,51 @@ export function GameInfoPanel({
{foundCount}/{totalRegions}
</div>
</div>
{/* Hint button - only show if hints are enabled */}
{currentDifficultyLevel?.hintsMode !== 'none' && (
<button
data-action="request-hint"
onClick={handleHint}
disabled={!hintsAvailable || isAnimating || state.gamePhase !== 'playing'}
className={css({
padding: '2',
fontSize: 'xs',
fontWeight: 'semibold',
bg: hintsAvailable
? isDark
? 'yellow.800'
: 'yellow.100'
: isDark
? 'gray.700'
: 'gray.200',
color: hintsAvailable
? isDark
? 'yellow.200'
: 'yellow.800'
: isDark
? 'gray.500'
: 'gray.500',
border: '2px solid',
borderColor: hintsAvailable ? 'yellow.500' : isDark ? 'gray.600' : 'gray.300',
rounded: 'md',
cursor: hintsAvailable ? 'pointer' : 'not-allowed',
opacity: hintsAvailable ? 1 : 0.6,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5',
_hover: hintsAvailable ? { bg: isDark ? 'yellow.700' : 'yellow.200' } : {},
})}
title={remainingHints !== null ? `${remainingHints} hints remaining` : 'Show hint'}
>
<span>💡</span>
{remainingHints !== null && (
<span className={css({ fontSize: '2xs' })}>{remainingHints}</span>
)}
</button>
)}
</div>
{/* Error Display - only shows when error exists */}
@ -208,7 +304,9 @@ export function GameInfoPanel({
})}
>
<span></span>
<div className={css({ flex: 1, fontWeight: 'bold' })}>Wrong! {lastError}</div>
<div className={css({ flex: 1, fontWeight: 'bold' })}>
Wrong!{formattedError ? ` ${formattedError}` : ''}
</div>
<button
onClick={clearError}
className={css({
@ -270,7 +368,11 @@ export function GameInfoPanel({
{state.gameMode === 'turn-based' && '↔️'}
</span>
<span>
{state.difficulty === 'learning' && '🌱'}
{state.difficulty === 'easy' && '😊'}
{state.difficulty === 'normal' && '🎯'}
{state.difficulty === 'expert' && '🏆'}
{/* Legacy fallback */}
{state.difficulty === 'hard' && '🤔'}
</span>
</div>

View File

@ -100,19 +100,23 @@ const Template = (args: StoryArgs) => {
correct: true,
}))
// Map difficulty to assistance level for rendering
const assistanceLevel = args.difficulty === 'easy' ? 'helpful' : 'standard'
return (
<div style={{ padding: '20px', minHeight: '100vh', background: '#111827' }}>
<MapRenderer
mapData={mapData}
regionsFound={regionsFound}
currentPrompt={mapData.regions[5]?.id || null}
difficulty={args.difficulty}
assistanceLevel={assistanceLevel}
selectedMap="world"
selectedContinent={args.continent}
onRegionClick={(id, name) => console.log('Clicked:', id, name)}
guessHistory={guessHistory}
playerMetadata={mockPlayerMetadata}
giveUpReveal={null}
hintActive={null}
onGiveUp={() => console.log('Give Up clicked')}
forceTuning={{
showArrows: args.showArrows,

View File

@ -15,6 +15,7 @@ import { forceSimulation, forceCollide, forceX, forceY, type SimulationNodeDatum
import {
WORLD_MAP,
USA_MAP,
ASSISTANCE_LEVELS,
filterRegionsByContinent,
parseViewBox,
calculateFitCropViewBox,
@ -200,7 +201,7 @@ interface MapRendererProps {
mapData: MapData
regionsFound: string[]
currentPrompt: string | null
difficulty: string // Difficulty level ID
assistanceLevel: '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
@ -225,6 +226,11 @@ interface MapRendererProps {
regionName: string
timestamp: number
} | null
// Hint highlight animation
hintActive: {
regionId: string
timestamp: number
} | null
// Give up callback
onGiveUp: () => void
// Force simulation tuning parameters
@ -301,13 +307,14 @@ export function MapRenderer({
mapData,
regionsFound,
currentPrompt,
difficulty,
assistanceLevel,
selectedMap,
selectedContinent,
onRegionClick,
guessHistory,
playerMetadata,
giveUpReveal,
hintActive,
onGiveUp,
forceTuning = {},
showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES,
@ -333,7 +340,7 @@ export function MapRenderer({
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Calculate excluded regions (regions filtered out by difficulty/continent)
// Calculate excluded regions (regions filtered out by size/continent)
const excludedRegions = useMemo(() => {
// Get full unfiltered map data
const fullMapData = selectedMap === 'world' ? WORLD_MAP : USA_MAP
@ -348,26 +355,16 @@ export function MapRenderer({
const includedRegionIds = new Set(mapData.regions.map((r) => r.id))
const excluded = allRegions.filter((r) => !includedRegionIds.has(r.id))
console.log('[MapRenderer] Excluded regions by difficulty:', {
total: allRegions.length,
included: includedRegionIds.size,
excluded: excluded.length,
excludedNames: excluded.map((r) => r.name),
})
// Debug: Check if Gibraltar is included or excluded
const gibraltar = allRegions.find((r) => r.id === 'gi')
const gibraltarIncluded = includedRegionIds.has('gi')
if (gibraltar) {
console.log('[Gibraltar Debug]', gibraltarIncluded ? '✅ INCLUDED' : '❌ EXCLUDED', {
inFilteredMap: gibraltarIncluded,
difficulty,
continent: selectedContinent,
})
}
return excluded
}, [mapData, selectedMap, selectedContinent, difficulty])
}, [mapData, selectedMap, selectedContinent])
// Get current assistance level config
const currentAssistanceLevel = useMemo(() => {
return ASSISTANCE_LEVELS.find((level) => level.id === assistanceLevel) || ASSISTANCE_LEVELS[1] // Default to 'helpful'
}, [assistanceLevel])
// Whether hot/cold is allowed by the assistance level (not user preference)
const assistanceAllowsHotCold = currentAssistanceLevel?.hotColdEnabled ?? false
// Create a set of excluded region IDs for quick lookup
const excludedRegionIds = useMemo(
@ -464,6 +461,10 @@ export function MapRenderer({
// Give up reveal animation state
const [giveUpFlashProgress, setGiveUpFlashProgress] = useState(0) // 0-1 pulsing value
const [isGiveUpAnimating, setIsGiveUpAnimating] = useState(false) // Track if animation in progress
// Hint animation state
const [hintFlashProgress, setHintFlashProgress] = useState(0) // 0-1 pulsing value
const [isHintAnimating, setIsHintAnimating] = useState(false) // Track if animation in progress
// Saved button position to prevent jumping during zoom animation
const [savedButtonPosition, setSavedButtonPosition] = useState<{
top: number
@ -722,11 +723,13 @@ export function MapRenderer({
const autoHintRef = useRef(autoHint)
const autoSpeakRef = useRef(autoSpeak)
const withAccentRef = useRef(withAccent)
const hotColdEnabledRef = useRef(hotColdEnabled)
// Hot/cold is only active when both: 1) assistance level allows it, 2) user has it enabled
const effectiveHotColdEnabled = assistanceAllowsHotCold && hotColdEnabled
const hotColdEnabledRef = useRef(effectiveHotColdEnabled)
autoHintRef.current = autoHint
autoSpeakRef.current = autoSpeak
withAccentRef.current = withAccent
hotColdEnabledRef.current = hotColdEnabled
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
@ -747,6 +750,7 @@ export function MapRenderer({
}, [currentPrompt, hasHint, hintText, isSpeechSupported, speakHint])
// Hot/cold audio feedback hook
// Only enabled if: 1) assistance level allows it, 2) user toggle is on, 3) not touch device
// Use continent name for language lookup if available, otherwise use selectedMap
const hotColdMapName = selectedContinent || selectedMap
const {
@ -754,7 +758,7 @@ export function MapRenderer({
reset: resetHotCold,
lastFeedbackType: hotColdFeedbackType,
} = useHotColdFeedback({
enabled: hotColdEnabled && !isTouchDevice,
enabled: assistanceAllowsHotCold && hotColdEnabled && !isTouchDevice,
targetRegionId: currentPrompt,
isSpeaking,
mapName: hotColdMapName,
@ -921,12 +925,6 @@ export function MapRenderer({
const cropRegion = parseViewBox(mapData.customCrop)
const result = calculateFitCropViewBox(originalBounds, cropRegion, containerAspect)
console.log('[MapRenderer] Calculated displayViewBox:', {
customCrop: mapData.customCrop,
originalViewBox: mapData.originalViewBox,
containerAspect: containerAspect.toFixed(2),
result,
})
return result
}, [mapData.customCrop, mapData.originalViewBox, mapData.viewBox, svgDimensions])
@ -1164,6 +1162,56 @@ export function MapRenderer({
}
}, [giveUpReveal?.timestamp]) // Re-run when timestamp changes
// Hint animation effect - brief pulse to highlight target region
useEffect(() => {
if (!hintActive) {
setHintFlashProgress(0)
setIsHintAnimating(false)
return
}
// Track if this effect has been cleaned up
let isCancelled = false
let animationFrameId: number | null = null
// Start animation
setIsHintAnimating(true)
// Animation: 2 pulses over 1.5 seconds (shorter than give-up)
const duration = 1500
const pulses = 2
const startTime = Date.now()
const animate = () => {
if (isCancelled) return
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Create pulsing effect: sin wave for smooth on/off
const pulseProgress = Math.sin(progress * Math.PI * pulses * 2) * 0.5 + 0.5
setHintFlashProgress(pulseProgress)
if (progress < 1) {
animationFrameId = requestAnimationFrame(animate)
} else {
// Animation complete
setHintFlashProgress(0)
setIsHintAnimating(false)
}
}
animationFrameId = requestAnimationFrame(animate)
// Cleanup
return () => {
isCancelled = true
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
}
}
}, [hintActive?.timestamp]) // Re-run when timestamp changes
// Keyboard shortcuts - Shift for magnifier, H for hint
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -1569,15 +1617,7 @@ export function MapRenderer({
setLabelPositions(positions)
setSmallRegionLabelPositions(smallPositions)
// Debug: Log summary
console.log('[MapRenderer] Label positions updated:', {
mapId: mapData.id,
totalRegions: mapData.regions.length,
regularLabels: positions.length,
smallRegionLabels: smallPositions.length,
viewBox: mapData.viewBox,
svgDimensions,
})
// Debug log removed to reduce spam
}
// Small delay to ensure ghost elements are rendered
@ -1604,10 +1644,10 @@ export function MapRenderer({
const viewBoxHeight = viewBoxParts[3] || 1000
const showOutline = (region: MapRegion): boolean => {
// Easy mode: always show outlines
if (difficulty === 'easy') return true
// Guided/Helpful modes: always show outlines
if (assistanceLevel === 'guided' || assistanceLevel === 'helpful') return true
// Medium/Hard mode: 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)
}
@ -2055,9 +2095,7 @@ export function MapRenderer({
svgRect.width
)
adaptiveZoom = Math.min(adaptiveZoom, maxZoom)
console.log(
`[Magnifier] Capping zoom at ${adaptiveZoom.toFixed(1)}× (threshold: ${PRECISION_MODE_THRESHOLD} px/px, would have been ${screenPixelRatio.toFixed(1)} px/px)`
)
// Zoom cap log removed to reduce spam
}
}
}
@ -2363,9 +2401,11 @@ export function MapRenderer({
const isFound = regionsFound.includes(region.id) || isExcluded // Treat excluded as pre-found
const playerId = !isExcluded && isFound ? getPlayerWhoFoundRegion(region.id) : null
const isBeingRevealed = giveUpReveal?.regionId === region.id
const isBeingHinted = hintActive?.regionId === region.id
// Special styling for excluded regions (grayed out, pre-labeled)
// Bright gold flash for give up reveal with high contrast
// Cyan flash for hint
const fill = isBeingRevealed
? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` // Brighter gold, higher base opacity
: isExcluded
@ -2415,6 +2455,18 @@ export function MapRenderer({
style={{ filter: 'blur(4px)' }}
/>
)}
{/* Glow effect for hint - cyan pulsing outline */}
{isBeingHinted && (
<path
d={region.path}
fill={`rgba(0, 200, 255, ${0.1 + hintFlashProgress * 0.3})`}
stroke={`rgba(0, 200, 255, ${0.4 + hintFlashProgress * 0.6})`}
strokeWidth={6}
vectorEffect="non-scaling-stroke"
style={{ filter: 'blur(3px)' }}
pointerEvents="none"
/>
)}
{/* Network hover border (crisp outline in player color) */}
{networkHover && !isBeingRevealed && (
<path
@ -4552,9 +4604,10 @@ export function MapRenderer({
)
})()}
{/* Hot/Cold button - only show on desktop with speech support */}
{/* Hot/Cold button - only show on desktop with speech support when assistance level allows */}
{isSpeechSupported &&
!isTouchDevice &&
assistanceAllowsHotCold &&
(() => {
if (!svgRef.current || !containerRef.current || svgDimensions.width === 0) return null

View File

@ -38,6 +38,11 @@ interface MapSelectorMapProps {
* Use this to disable hover on non-interactive regions.
*/
hoverableRegions?: string[]
/**
* Regions that are excluded by region size filtering.
* These will be shown dimmed/grayed out.
*/
excludedRegions?: string[]
}
export function MapSelectorMap({
@ -51,6 +56,7 @@ export function MapSelectorMap({
regionGroups,
selectedGroup,
hoverableRegions,
excludedRegions = [],
}: MapSelectorMapProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -92,12 +98,23 @@ export function MapSelectorMap({
return thisGroup === selectedGroup
}
// Check if a region is excluded by size filtering
const isRegionExcluded = (regionId: string): boolean => {
return excludedRegions.includes(regionId)
}
// Get fill color for a region
const getRegionFill = (regionId: string): string => {
const isExcluded = isRegionExcluded(regionId)
const isHovered = isRegionHighlighted(regionId)
const isSelected = isRegionSelected(regionId)
const hasSubMap = highlightedRegions.includes(regionId)
// Excluded regions are dimmed
if (isExcluded) {
return isDark ? '#1f2937' : '#e5e7eb' // Gray out excluded regions
}
// Use the game's color algorithm
const baseColor = getRegionColor(regionId, false, isHovered, isDark)
@ -122,10 +139,16 @@ export function MapSelectorMap({
// Get stroke color for a region
const getRegionStroke = (regionId: string): string => {
const isExcluded = isRegionExcluded(regionId)
const isHovered = isRegionHighlighted(regionId)
const isSelected = isRegionSelected(regionId)
const hasSubMap = highlightedRegions.includes(regionId)
// Excluded regions get subtle stroke
if (isExcluded) {
return isDark ? '#374151' : '#d1d5db'
}
if (isHovered) {
return isDark ? '#60a5fa' : '#1d4ed8'
}
@ -191,27 +214,32 @@ export function MapSelectorMap({
/>
{/* Render each region */}
{displayRegions.map((region) => (
<path
key={region.id}
data-region={region.id}
d={region.path}
fill={getRegionFill(region.id)}
stroke={getRegionStroke(region.id)}
strokeWidth={getRegionStrokeWidth(region.id)}
onMouseEnter={() => onRegionHover(region.id)}
onMouseLeave={() => onRegionHover(null)}
onClick={(e) => {
e.stopPropagation()
onRegionClick(region.id)
}}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
pointerEvents: 'all',
}}
/>
))}
{displayRegions.map((region) => {
const isExcluded = excludedRegions.includes(region.id)
return (
<path
key={region.id}
data-region={region.id}
data-excluded={isExcluded ? 'true' : undefined}
d={region.path}
fill={getRegionFill(region.id)}
stroke={getRegionStroke(region.id)}
strokeWidth={getRegionStrokeWidth(region.id)}
onMouseEnter={() => onRegionHover(region.id)}
onMouseLeave={() => onRegionHover(null)}
onClick={(e) => {
e.stopPropagation()
onRegionClick(region.id)
}}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
pointerEvents: 'all',
opacity: isExcluded ? 0.5 : 1,
}}
/>
)
})}
</svg>
</div>
)

View File

@ -55,16 +55,16 @@ export function PlayingPhase() {
const currentRegionName = currentRegion?.name ?? null
const currentRegionId = currentRegion?.id ?? null
// Debug logging
console.log('[PlayingPhase] Current prompt lookup:', {
currentPrompt: state.currentPrompt,
currentRegionName,
difficulty: state.difficulty,
totalFilteredRegions: mapData.regions.length,
filteredRegionIds: mapData.regions.map((r) => r.id).slice(0, 10),
regionsToFindCount: state.regionsToFind.length,
regionsToFindSample: state.regionsToFind.slice(0, 5),
})
// Debug warning if prompt not found in filtered regions (indicates server/client filter mismatch)
if (state.currentPrompt && !currentRegion) {
console.warn('[PlayingPhase] Prompt not in filtered regions - server/client filter mismatch:', {
currentPrompt: state.currentPrompt,
difficulty: state.difficulty,
selectedContinent: state.selectedContinent,
clientFilteredCount: mapData.regions.length,
serverRegionsToFindCount: state.regionsToFind.length,
})
}
return (
<div
@ -139,13 +139,14 @@ export function PlayingPhase() {
mapData={mapData}
regionsFound={state.regionsFound}
currentPrompt={state.currentPrompt}
difficulty={state.difficulty}
assistanceLevel={state.assistanceLevel}
selectedMap={state.selectedMap}
selectedContinent={state.selectedContinent}
onRegionClick={clickRegion}
guessHistory={state.guessHistory}
playerMetadata={state.playerMetadata}
giveUpReveal={state.giveUpReveal}
hintActive={state.hintActive ?? null}
onGiveUp={giveUp}
gameMode={state.gameMode}
currentPlayer={state.currentPlayer}

View File

@ -1,14 +1,47 @@
'use client'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import * as Select from '@radix-ui/react-select'
import * as Checkbox from '@radix-ui/react-checkbox'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import { useKnowYourWorld } from '../Provider'
import { DrillDownMapSelector } from './DrillDownMapSelector'
import { WORLD_MAP, USA_MAP, DEFAULT_DIFFICULTY_CONFIG } from '../maps'
import {
ALL_REGION_SIZES,
ASSISTANCE_LEVELS,
getFilteredMapDataBySizesSync,
REGION_SIZE_CONFIG,
} from '../maps'
import type { RegionSize, AssistanceLevelConfig } from '../maps'
import type { ContinentId } from '../continents'
// Get term for regions based on map type
function getRegionTerm(selectedMap: 'world' | 'usa'): string {
return selectedMap === 'world' ? 'countries' : 'states'
}
// Generate feature badges for an assistance level
function getFeatureBadges(level: AssistanceLevelConfig): Array<{ label: string; icon: string }> {
const badges: Array<{ label: string; icon: string }> = []
if (level.hotColdEnabled) {
badges.push({ label: 'Hot/cold', icon: '🔥' })
}
if (level.hintsMode === 'onRequest') {
if (level.autoHintDefault) {
badges.push({ label: 'Auto-hints', icon: '💡' })
} else {
badges.push({ label: 'Hints', icon: '💡' })
}
} else if (level.hintsMode === 'limited' && level.hintLimit) {
badges.push({ label: `${level.hintLimit} hints`, icon: '💡' })
}
return badges
}
// Game mode options with rich descriptions
const GAME_MODE_OPTIONS = [
{
@ -62,12 +95,42 @@ const STUDY_TIME_OPTIONS = [
export function SetupPhase() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { state, startGame, setMap, setMode, setDifficulty, setStudyDuration, setContinent } =
useKnowYourWorld()
const {
state,
startGame,
setMap,
setMode,
setRegionSizes,
setAssistanceLevel,
setStudyDuration,
setContinent,
} = useKnowYourWorld()
// Get difficulty config for current map
const mapData = state.selectedMap === 'world' ? WORLD_MAP : USA_MAP
const difficultyConfig = mapData.difficultyConfig || DEFAULT_DIFFICULTY_CONFIG
// Calculate region counts per size category
const regionCountsBySize = useMemo(() => {
const counts: Record<string, number> = {}
for (const size of ALL_REGION_SIZES) {
try {
const filteredData = getFilteredMapDataBySizesSync(
state.selectedMap,
state.selectedContinent,
[size]
)
counts[size] = filteredData.regions.length
} catch {
counts[size] = 0
}
}
return counts
}, [state.selectedMap, state.selectedContinent])
// Calculate the total region count for current selection
const totalRegionCount = useMemo(() => {
return state.includeSizes.reduce((sum, size) => sum + (regionCountsBySize[size] || 0), 0)
}, [state.includeSizes, regionCountsBySize])
// Get the term for regions (countries/states)
const regionTerm = getRegionTerm(state.selectedMap)
// Handle selection change from drill-down selector
const handleSelectionChange = useCallback(
@ -81,7 +144,21 @@ export function SetupPhase() {
// Get selected options for display
const selectedMode = GAME_MODE_OPTIONS.find((opt) => opt.value === state.gameMode)
const selectedStudyTime = STUDY_TIME_OPTIONS.find((opt) => opt.value === state.studyDuration)
const selectedDifficulty = difficultyConfig.levels.find((level) => level.id === state.difficulty)
const selectedAssistance = ASSISTANCE_LEVELS.find((level) => level.id === state.assistanceLevel)
// Handle toggling a region size
const toggleRegionSize = useCallback(
(size: RegionSize) => {
if (state.includeSizes.includes(size)) {
// Don't allow removing the last size
if (state.includeSizes.length === 1) return
setRegionSizes(state.includeSizes.filter((s) => s !== size))
} else {
setRegionSizes([...state.includeSizes, size])
}
},
[state.includeSizes, setRegionSizes]
)
// Styles for Radix Select components
const triggerStyles = css({
@ -192,6 +269,7 @@ export function SetupPhase() {
selectedContinent={state.selectedContinent}
onSelectionChange={handleSelectionChange}
onStartGame={startGame}
includeSizes={state.includeSizes}
/>
</div>
@ -281,48 +359,78 @@ export function SetupPhase() {
</Select.Root>
</div>
{/* Difficulty (only show if multiple levels) */}
{difficultyConfig.levels.length > 1 && (
<div
data-setting="difficulty"
className={css({ display: 'flex', flexDirection: 'column' })}
{/* Assistance Level */}
<div
data-setting="assistance-level"
className={css({ display: 'flex', flexDirection: 'column' })}
>
<label className={labelStyles}>Assistance</label>
<Select.Root
value={state.assistanceLevel}
onValueChange={(value) =>
setAssistanceLevel(value as 'guided' | 'helpful' | 'standard' | 'none')
}
>
<label className={labelStyles}>Difficulty</label>
<Select.Root value={state.difficulty} onValueChange={setDifficulty}>
<Select.Trigger className={triggerStyles}>
<span className={css({ fontSize: '2xl' })}>
{selectedDifficulty?.emoji || '🎯'}
</span>
<div className={css({ flex: 1, textAlign: 'left' })}>
<div
className={css({
fontWeight: '600',
color: isDark ? 'gray.100' : 'gray.900',
fontSize: 'sm',
})}
>
{selectedDifficulty?.label}
</div>
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: 'tight',
})}
>
{selectedDifficulty?.description || 'Select difficulty level'}
</div>
<Select.Trigger className={triggerStyles}>
<span className={css({ fontSize: '2xl' })}>{selectedAssistance?.emoji || '💡'}</span>
<div className={css({ flex: 1, textAlign: 'left' })}>
<div
className={css({
fontWeight: '600',
color: isDark ? 'gray.100' : 'gray.900',
fontSize: 'sm',
})}
>
{selectedAssistance?.label}
</div>
<Select.Icon className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className={contentStyles} position="popper" sideOffset={5}>
<Select.Viewport>
{difficultyConfig.levels.map((level) => (
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: 'tight',
})}
>
{selectedAssistance?.description}
</div>
{/* Feature badges */}
{selectedAssistance && (
<div
className={css({
display: 'flex',
gap: '1',
marginTop: '1',
flexWrap: 'wrap',
})}
>
{getFeatureBadges(selectedAssistance).map((badge) => (
<span
key={badge.label}
className={css({
fontSize: '2xs',
padding: '0.5 1',
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.600',
rounded: 'sm',
})}
>
{badge.icon} {badge.label}
</span>
))}
</div>
)}
</div>
<Select.Icon className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className={contentStyles} position="popper" sideOffset={5}>
<Select.Viewport>
{ASSISTANCE_LEVELS.map((level) => {
const badges = getFeatureBadges(level)
return (
<Select.Item key={level.id} value={level.id} className={itemStyles}>
<span className={css({ fontSize: '2xl' })}>{level.emoji || '🎯'}</span>
<span className={css({ fontSize: '2xl' })}>{level.emoji}</span>
<div className={css({ flex: 1 })}>
<Select.ItemText>
<span
@ -342,17 +450,41 @@ export function SetupPhase() {
lineHeight: 'tight',
})}
>
{level.description || `${level.label} difficulty`}
{level.description}
</div>
{/* Feature badges */}
<div
className={css({
display: 'flex',
gap: '1',
marginTop: '1',
flexWrap: 'wrap',
})}
>
{badges.map((badge) => (
<span
key={badge.label}
className={css({
fontSize: '2xs',
padding: '0.5 1',
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.600',
rounded: 'sm',
})}
>
{badge.icon} {badge.label}
</span>
))}
</div>
</div>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
)}
)
})}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
{/* Study Duration */}
<div
@ -431,6 +563,146 @@ export function SetupPhase() {
</div>
</div>
{/* Region Types Selection */}
<div
data-section="region-sizes"
className={css({
padding: '5',
bg: isDark ? 'gray.800/50' : 'gray.50',
rounded: '2xl',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4',
})}
>
<label className={labelStyles} style={{ marginBottom: 0 }}>
Region Types
</label>
<span
className={css({
fontSize: 'sm',
fontWeight: '600',
color: isDark ? 'blue.300' : 'blue.600',
})}
>
{totalRegionCount} {regionTerm} selected
</span>
</div>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '2',
})}
>
{ALL_REGION_SIZES.map((size) => {
const config = REGION_SIZE_CONFIG[size]
const isChecked = state.includeSizes.includes(size)
const isOnlyOne = state.includeSizes.length === 1 && isChecked
const count = regionCountsBySize[size] || 0
return (
<Checkbox.Root
key={size}
checked={isChecked}
onCheckedChange={() => toggleRegionSize(size)}
disabled={isOnlyOne}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
paddingX: '3',
paddingY: '2',
bg: isChecked
? isDark
? 'blue.800'
: 'blue.500'
: isDark
? 'gray.700'
: 'white',
border: '1px solid',
borderColor: isChecked
? isDark
? 'blue.600'
: 'blue.600'
: isDark
? 'gray.600'
: 'gray.300',
rounded: 'full',
cursor: isOnlyOne ? 'not-allowed' : 'pointer',
opacity: isOnlyOne ? 0.5 : 1,
transition: 'all 0.15s',
_hover: isOnlyOne
? {}
: {
bg: isChecked
? isDark
? 'blue.700'
: 'blue.600'
: isDark
? 'gray.600'
: 'gray.100',
},
_focus: {
outline: 'none',
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.3)',
},
})}
>
<span className={css({ fontSize: 'base' })}>{config.emoji}</span>
<span
className={css({
fontWeight: '500',
color: isChecked
? 'white'
: isDark
? 'gray.200'
: 'gray.700',
fontSize: 'sm',
})}
>
{config.label}
</span>
<span
className={css({
fontWeight: '600',
fontSize: 'xs',
color: isChecked
? isDark
? 'blue.200'
: 'blue.100'
: isDark
? 'gray.400'
: 'gray.500',
bg: isChecked
? isDark
? 'blue.700'
: 'blue.600'
: isDark
? 'gray.600'
: 'gray.200',
paddingX: '1.5',
paddingY: '0.5',
rounded: 'full',
minWidth: '6',
textAlign: 'center',
})}
>
{count}
</span>
</Checkbox.Root>
)
})}
</div>
</div>
{/* Tips Section */}
<div
data-element="tips"
@ -443,10 +715,12 @@ export function SetupPhase() {
textAlign: 'center',
})}
>
<strong>Tip:</strong> Press G or click "Give Up" to skip a region you don't know.{' '}
{state.difficulty === 'easy'
? 'On Easy, skipped regions are re-asked after a few turns.'
: 'On Hard, skipped regions are re-asked at the end.'}
<strong>Tip:</strong> Press G to give up on a region.{' '}
{(state.assistanceLevel === 'guided' || state.assistanceLevel === 'helpful') &&
'Skipped regions return after 2-3 turns.'}
{state.assistanceLevel === 'standard' && 'Giving up counts against your score.'}
{state.assistanceLevel === 'none' && 'No assistance available in this mode.'}
{selectedAssistance?.hintsMode === 'onRequest' && ' Press H for hints!'}
</div>
</div>
)

View File

@ -30,7 +30,8 @@ Choose from multiple maps (World, USA States) and difficulty levels!`,
const defaultConfig: KnowYourWorldConfig = {
selectedMap: 'world',
gameMode: 'cooperative',
difficulty: 'medium',
includeSizes: ['huge', 'large', 'medium'],
assistanceLevel: 'helpful',
studyDuration: 0,
selectedContinent: 'all',
}
@ -47,19 +48,26 @@ function validateKnowYourWorldConfig(config: unknown): config is KnowYourWorldCo
'antarctica',
]
const validSizes = ['huge', 'large', 'medium', 'small', 'tiny']
const validAssistanceLevels = ['guided', 'helpful', 'standard', 'none']
return (
typeof config === 'object' &&
config !== null &&
'selectedMap' in config &&
'gameMode' in config &&
'difficulty' in config &&
'includeSizes' in config &&
'assistanceLevel' in config &&
'studyDuration' in config &&
'selectedContinent' in config &&
(config.selectedMap === 'world' || config.selectedMap === 'usa') &&
(config.gameMode === 'cooperative' ||
config.gameMode === 'race' ||
config.gameMode === 'turn-based') &&
typeof config.difficulty === 'string' &&
Array.isArray(config.includeSizes) &&
config.includeSizes.every((s: unknown) => typeof s === 'string' && validSizes.includes(s)) &&
typeof config.assistanceLevel === 'string' &&
validAssistanceLevels.includes(config.assistanceLevel) &&
(config.studyDuration === 0 ||
config.studyDuration === 30 ||
config.studyDuration === 60 ||

View File

@ -87,71 +87,740 @@ if (typeof window !== 'undefined') {
})
}
/**
* Region size category for difficulty-based filtering
*/
export type RegionSize = 'huge' | 'large' | 'medium' | 'small' | 'tiny'
/**
* Hints mode for difficulty levels
*/
export type HintsMode = 'onRequest' | 'limited' | 'none'
/**
* Give up behavior mode
*/
export type GiveUpMode = 'reaskSoon' | 'reaskEnd' | 'countsAgainst' | 'skipEntirely'
/**
* Difficulty level configuration for a map
*/
export interface DifficultyLevel {
id: string // e.g., 'easy', 'medium', 'hard', 'standard'
id: string // e.g., 'learning', 'easy', 'normal', 'expert'
label: string // Display name
emoji?: string // Optional emoji
description?: string // Optional description for UI
// Filtering: either explicit exclusions OR percentage
description?: string // Short description for UI
detailedDescription?: string // Longer description for tooltip/modal
// Region filtering (new size-based system)
includeSizes?: RegionSize[] // Which size categories to include
// Legacy filtering (for backwards compatibility)
excludeRegions?: string[] // Explicit region IDs to exclude
keepPercentile?: number // 0-1, keep this % of largest regions (default 1.0)
// Feature flags
hotColdEnabled?: boolean // Hot/cold feedback when clicking wrong regions
hintsMode?: HintsMode // How hints work
hintLimit?: number // For 'limited' mode, how many hints per game
autoHintDefault?: boolean // Default state of auto-hint checkbox
struggleHintEnabled?: boolean // Show hint after struggling (timer-based)
giveUpMode?: GiveUpMode // What happens when player gives up
wrongClickShowsName?: boolean // Show "That was [name]" vs just "Wrong!"
}
/**
* Per-map difficulty configuration
* @deprecated Use AssistanceLevelConfig instead - difficulty conflated region filtering with assistance
*/
export interface MapDifficultyConfig {
levels: DifficultyLevel[]
defaultLevel: string // ID of default level
}
/**
* Assistance level configuration - controls gameplay features separate from region filtering
*/
export interface AssistanceLevelConfig {
id: 'guided' | 'helpful' | 'standard' | 'none'
label: string
emoji: string
description: string
// Feature flags
hotColdEnabled: boolean
hintsMode: HintsMode
hintLimit?: number // For 'limited' mode
autoHintDefault: boolean
struggleHintEnabled: boolean
giveUpMode: GiveUpMode
wrongClickShowsName: boolean
}
/**
* Assistance levels - separate from region filtering
*/
export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
{
id: 'guided',
label: 'Guided',
emoji: '🎓',
description: 'Maximum help - hot/cold feedback, auto-hints, shows names on wrong clicks',
hotColdEnabled: true,
hintsMode: 'onRequest',
autoHintDefault: true,
struggleHintEnabled: true,
giveUpMode: 'reaskSoon',
wrongClickShowsName: true,
},
{
id: 'helpful',
label: 'Helpful',
emoji: '💡',
description: 'Hot/cold feedback and hints available on request',
hotColdEnabled: true,
hintsMode: 'onRequest',
autoHintDefault: false,
struggleHintEnabled: false,
giveUpMode: 'reaskEnd',
wrongClickShowsName: true,
},
{
id: 'standard',
label: 'Standard',
emoji: '🎯',
description: 'Limited hints (3), no hot/cold feedback',
hotColdEnabled: false,
hintsMode: 'limited',
hintLimit: 3,
autoHintDefault: false,
struggleHintEnabled: false,
giveUpMode: 'countsAgainst',
wrongClickShowsName: false,
},
{
id: 'none',
label: 'No Assistance',
emoji: '🏆',
description: 'Pure challenge - no hints or feedback',
hotColdEnabled: false,
hintsMode: 'none',
autoHintDefault: false,
struggleHintEnabled: false,
giveUpMode: 'skipEntirely',
wrongClickShowsName: false,
},
]
/**
* Get assistance level config by ID
*/
export function getAssistanceLevel(id: string): AssistanceLevelConfig {
return ASSISTANCE_LEVELS.find((l) => l.id === id) || ASSISTANCE_LEVELS[1] // Default to 'helpful'
}
/**
* Default region sizes to include (all sizes = complete set)
*/
export const ALL_REGION_SIZES: RegionSize[] = ['huge', 'large', 'medium', 'small', 'tiny']
/**
* Display configuration for each region size
*/
export const REGION_SIZE_CONFIG: Record<RegionSize, { label: string; emoji: string; description: string }> = {
huge: {
label: 'Major',
emoji: '🌍',
description: 'Large, well-known countries/states',
},
large: {
label: 'Large',
emoji: '🗺️',
description: 'Large territories',
},
medium: {
label: 'Medium',
emoji: '📍',
description: 'Mid-sized territories',
},
small: {
label: 'Small',
emoji: '🏝️',
description: 'Small territories and islands',
},
tiny: {
label: 'Tiny',
emoji: '🔍',
description: 'Microstates and remote territories',
},
}
/**
* Default assistance level
*/
export const DEFAULT_ASSISTANCE_LEVEL = 'helpful'
/**
* Default region sizes (medium difficulty - most regions)
*/
export const DEFAULT_REGION_SIZES: RegionSize[] = ['huge', 'large', 'medium']
/**
* Global default difficulty config for maps without custom config
* @deprecated - kept for backwards compatibility, use ASSISTANCE_LEVELS instead
* New 4-level system: Learning, Easy, Normal, Expert
*/
export const DEFAULT_DIFFICULTY_CONFIG: MapDifficultyConfig = {
levels: [
{
id: 'learning',
label: 'Learning',
emoji: '🌱',
description: 'Guided exploration with maximum help',
detailedDescription:
'Large countries only (~57). Hot/cold feedback guides you. Hints auto-open and appear if stuck.',
includeSizes: ['huge', 'large'],
hotColdEnabled: true,
hintsMode: 'onRequest',
autoHintDefault: true,
struggleHintEnabled: true,
giveUpMode: 'reaskSoon',
wrongClickShowsName: true,
},
{
id: 'easy',
label: 'Easy',
emoji: '😊',
description: '85% largest regions',
keepPercentile: 0.85,
description: 'Helpful feedback as you learn',
detailedDescription: 'Most countries (~163). Hot/cold feedback. Press H for hints anytime.',
includeSizes: ['huge', 'large', 'medium'],
hotColdEnabled: true,
hintsMode: 'onRequest',
autoHintDefault: false,
struggleHintEnabled: false,
giveUpMode: 'reaskEnd',
wrongClickShowsName: true,
},
{
id: 'medium',
label: 'Medium',
emoji: '🙂',
description: '98% largest regions',
keepPercentile: 0.98,
id: 'normal',
label: 'Normal',
emoji: '🎯',
description: 'Standard challenge',
detailedDescription: 'Nearly all countries (~223). No hot/cold. 3 hints per game.',
includeSizes: ['huge', 'large', 'medium', 'small'],
hotColdEnabled: false,
hintsMode: 'limited',
hintLimit: 3,
autoHintDefault: false,
struggleHintEnabled: false,
giveUpMode: 'countsAgainst',
wrongClickShowsName: false,
},
{
id: 'hard',
label: 'Hard',
emoji: '😰',
description: 'All regions',
keepPercentile: 1.0,
id: 'expert',
label: 'Expert',
emoji: '🏆',
description: 'Test your knowledge',
detailedDescription: 'All countries including tiny islands (256). No hints. Pure geography.',
includeSizes: ['huge', 'large', 'medium', 'small', 'tiny'],
hotColdEnabled: false,
hintsMode: 'none',
autoHintDefault: false,
struggleHintEnabled: false,
giveUpMode: 'skipEntirely',
wrongClickShowsName: false,
},
],
defaultLevel: 'medium',
defaultLevel: 'easy',
}
/**
* USA map difficulty config - single level, all regions
* USA state size categories (by geographic area and recognition)
* Uses state abbreviations (lowercase) from @svg-maps/usa
*/
const USA_STATE_SIZE_CATEGORIES: Record<RegionSize, Set<string>> = {
// Huge: Instantly recognizable, largest states (~8)
huge: new Set([
'ca', // California
'tx', // Texas
'fl', // Florida
'ny', // New York
'ak', // Alaska
'mt', // Montana
'az', // Arizona
'nv', // Nevada
]),
// Large: Major states, clearly visible (~10)
large: new Set([
'nm', // New Mexico
'co', // Colorado
'or', // Oregon
'wa', // Washington
'ut', // Utah
'wy', // Wyoming
'mi', // Michigan
'il', // Illinois
'pa', // Pennsylvania
'oh', // Ohio
]),
// Medium: Recognizable states, moderate size (~17)
medium: new Set([
'id', // Idaho
'nd', // North Dakota
'sd', // South Dakota
'ne', // Nebraska
'ks', // Kansas
'ok', // Oklahoma
'mn', // Minnesota
'ia', // Iowa
'mo', // Missouri
'ar', // Arkansas
'la', // Louisiana
'wi', // Wisconsin
'in', // Indiana
'ga', // Georgia
'nc', // North Carolina
'va', // Virginia
'tn', // Tennessee
]),
// Small: Smaller states (~10)
small: new Set([
'sc', // South Carolina
'al', // Alabama
'ms', // Mississippi
'ky', // Kentucky
'wv', // West Virginia
'md', // Maryland
'nj', // New Jersey
'ma', // Massachusetts
'me', // Maine
'hi', // Hawaii
]),
// Tiny: Small states, harder to find (~6)
tiny: new Set([
'vt', // Vermont
'nh', // New Hampshire
'ct', // Connecticut
'ri', // Rhode Island
'de', // Delaware
'dc', // District of Columbia
]),
}
/**
* Get the size category for a US state
*/
function getUSAStateSizeCategory(stateId: string): RegionSize | null {
for (const [size, ids] of Object.entries(USA_STATE_SIZE_CATEGORIES)) {
if (ids.has(stateId)) {
return size as RegionSize
}
}
return null
}
/**
* Check if a US state should be included based on difficulty level's size requirements
*/
function shouldIncludeUSAState(stateId: string, includeSizes: RegionSize[]): boolean {
const category = getUSAStateSizeCategory(stateId)
if (!category) {
// If no category found, include by default
return true
}
return includeSizes.includes(category)
}
/**
* USA map difficulty config - 4 levels like world map
*/
export const USA_DIFFICULTY_CONFIG: MapDifficultyConfig = {
levels: [
{
id: 'standard',
label: 'All States',
emoji: '🗺️',
description: 'All 50 states + DC',
keepPercentile: 1.0,
id: 'learning',
label: 'Learning',
emoji: '🌱',
description: 'Guided exploration with maximum help',
detailedDescription:
'Major states only (~18). Hot/cold feedback guides you. Hints auto-open and appear if stuck.',
includeSizes: ['huge', 'large'],
hotColdEnabled: true,
hintsMode: 'onRequest',
autoHintDefault: true,
struggleHintEnabled: true,
wrongClickShowsName: true,
giveUpMode: 'reaskSoon',
},
{
id: 'easy',
label: 'Easy',
emoji: '😊',
description: 'Comfortable challenge with guidance',
detailedDescription: 'Most states (~35). Hot/cold feedback. Press H for hints anytime.',
includeSizes: ['huge', 'large', 'medium'],
hotColdEnabled: true,
hintsMode: 'onRequest',
autoHintDefault: false,
struggleHintEnabled: false,
wrongClickShowsName: true,
giveUpMode: 'reaskEnd',
},
{
id: 'normal',
label: 'Normal',
emoji: '🎯',
description: 'Standard challenge with limited hints',
detailedDescription: 'Nearly all states (~45). No hot/cold. 3 hints per game.',
includeSizes: ['huge', 'large', 'medium', 'small'],
hotColdEnabled: false,
hintsMode: 'limited',
hintLimit: 3,
autoHintDefault: false,
struggleHintEnabled: false,
giveUpMode: 'countsAgainst',
wrongClickShowsName: false,
},
{
id: 'expert',
label: 'Expert',
emoji: '🏆',
description: 'Full challenge, no assistance',
detailedDescription: 'All 51 states/territories. No hints. Pure geography.',
includeSizes: ['huge', 'large', 'medium', 'small', 'tiny'],
hotColdEnabled: false,
hintsMode: 'none',
autoHintDefault: false,
struggleHintEnabled: false,
giveUpMode: 'skipEntirely',
wrongClickShowsName: false,
},
],
defaultLevel: 'standard',
defaultLevel: 'easy',
}
/**
* Region size categories for world map
* Curated based on actual geographic size and prominence (not bounding box area)
* ISO 3166-1 alpha-2 codes (lowercase)
*/
const REGION_SIZE_CATEGORIES: Record<RegionSize, Set<string>> = {
// Huge: Major powers, instantly recognizable (~15)
huge: new Set([
'ru', // Russia
'cn', // China
'us', // United States
'ca', // Canada
'br', // Brazil
'au', // Australia
'in', // India
'ar', // Argentina
'kz', // Kazakhstan
'dz', // Algeria
'cd', // DR Congo
'sa', // Saudi Arabia
'mx', // Mexico
'id', // Indonesia
'ly', // Libya
]),
// Large: Major countries, clearly visible (~42)
large: new Set([
'sd',
'ir',
'mn',
'pe',
'td',
'ne',
'ao',
'ml',
'za',
'co',
've',
'et',
'eg',
'mr',
'bo',
'ng',
'tz',
'cl',
'zm',
'mm',
'af',
'so',
'cf',
'ss',
'mg',
'mz',
'pk',
'tr',
'ke',
'fr',
'th',
'es',
'cm',
'pg',
'ma',
'ua',
'jp',
'de',
'pl',
'no',
'se',
'fi',
]),
// Medium: Recognizable countries, moderate size (~106)
medium: new Set([
// Europe
'gb',
'it',
'ro',
'gr',
'bg',
'hu',
'by',
'at',
'cz',
'rs',
'ie',
'lt',
'lv',
'hr',
'ba',
'sk',
'ee',
'dk',
'nl',
'be',
'ch',
'pt',
'al',
'md',
'mk',
'si',
'me',
'xk',
'is',
// Asia
'vn',
'my',
'ph',
'np',
'bd',
'kh',
'la',
'kp',
'kr',
'tw',
'uz',
'tm',
'kg',
'tj',
'iq',
'sy',
'jo',
'il',
'lb',
'az',
'ge',
'am',
'ye',
'om',
'ae',
'bt',
'ps',
'tl',
// Africa
'ci',
'bf',
'gh',
'gn',
'sn',
'ug',
'ga',
'tg',
'bj',
'er',
'mw',
'ls',
'sz',
'rw',
'bi',
'sl',
'lr',
'gm',
'gw',
'cg',
'gq',
'dj',
'tn',
'bw',
'na',
'zw',
'eh',
// Americas
'ec',
'py',
'uy',
'sr',
'gy',
'pa',
'cr',
'ni',
'hn',
'gt',
'bz',
'sv',
'cu',
'do',
'ht',
'jm',
'bs',
'tt',
'gf',
'gl',
// Oceania
'nz',
'fj',
]),
// Small: Smaller countries, harder to find (~60)
small: new Set([
// Caribbean
'bb',
'ag',
'dm',
'lc',
'vc',
'gd',
'kn',
'aw',
'cw',
'bq',
'sx',
'mf',
'bl',
'tc',
'vg',
'vi',
'ky',
'ai',
'ms',
'pr',
'bm',
'gp',
'mq',
// Europe
'lu',
'cy',
'mt',
'ax',
'fo',
'gg',
'im',
'je',
// Middle East
'kw',
'qa',
'bh',
// Africa
'cv',
'st',
'km',
'mu',
're',
'yt',
'sc',
'sh',
// Asia
'bn',
'sg',
'hk',
'mo',
'mv',
'lk',
'pm',
// Oceania
'ws',
'to',
'vu',
'sb',
'nc',
'pf',
'gu',
'as',
'mp',
'pw',
'fm',
]),
// Tiny: Microstates and tiny islands, very hard to find (~33)
tiny: new Set([
// Europe
'va',
'mc',
'sm',
'ad',
'li',
'gi',
// Pacific
'nr',
'tv',
'mh',
'ki',
'nu',
'tk',
'ck',
'wf',
'pn',
// Other territories
'io',
'cx',
'cc',
'nf',
'hm',
'bv',
'sj',
'fk',
'gs',
'aq',
'tf',
'go',
'ju', // French territories
'um-dq',
'um-fq',
'um-hq',
'um-jq',
'um-mq',
'um-wq', // US Minor Outlying Islands
]),
}
/**
* Get the size category for a region ID
*/
export function getRegionSizeCategory(regionId: string): RegionSize | null {
for (const [size, ids] of Object.entries(REGION_SIZE_CATEGORIES)) {
if (ids.has(regionId)) {
return size as RegionSize
}
}
return null // Region not categorized (shouldn't happen for world map)
}
/**
* Check if a region should be included based on difficulty level's size requirements
*/
export function shouldIncludeRegion(regionId: string, includeSizes: RegionSize[]): boolean {
const category = getRegionSizeCategory(regionId)
if (!category) {
// If no category found, include by default (for regions not in our list)
return true
}
return includeSizes.includes(category)
}
/**
@ -686,7 +1355,6 @@ export function calculateContinentViewBox(
// Check for custom crop override first
const customCrop = getCustomCrop(mapId, continentId)
if (customCrop) {
console.log(`[Maps] Using custom crop for ${mapId}/${continentId}: ${customCrop}`)
return customCrop
}
@ -837,11 +1505,45 @@ function calculateRegionArea(pathString: string): number {
}
/**
* Filter regions based on difficulty level configuration
* Supports both explicit region exclusions and percentile-based filtering
* Filter regions by size categories (primary filtering function)
* This is the new clean API that takes sizes directly
*/
function filterRegionsByDifficulty(regions: MapRegion[], level: DifficultyLevel): MapRegion[] {
// Explicit exclusions take priority
export function filterRegionsBySizes(
regions: MapRegion[],
includeSizes: RegionSize[],
mapId: 'world' | 'usa' = 'world'
): MapRegion[] {
// If all sizes included or empty array, return all regions
if (includeSizes.length === 0 || includeSizes.length === ALL_REGION_SIZES.length) {
return regions
}
// Use appropriate size checker based on map
const shouldInclude =
mapId === 'usa'
? (id: string) => shouldIncludeUSAState(id, includeSizes)
: (id: string) => shouldIncludeRegion(id, includeSizes)
const filtered = regions.filter((r) => shouldInclude(r.id))
return filtered
}
/**
* Filter regions based on difficulty level configuration
* @deprecated Use filterRegionsBySizes instead
* Supports: 1) Size-based filtering (new), 2) Explicit exclusions, 3) Percentile-based (legacy)
*/
function filterRegionsByDifficulty(
regions: MapRegion[],
level: DifficultyLevel,
mapId: 'world' | 'usa' = 'world'
): MapRegion[] {
// 1. Size-based filtering (new system - highest priority)
if (level.includeSizes && level.includeSizes.length > 0) {
return filterRegionsBySizes(regions, level.includeSizes, mapId)
}
// 2. Explicit exclusions
if (level.excludeRegions && level.excludeRegions.length > 0) {
const filtered = regions.filter((r) => !level.excludeRegions!.includes(r.id))
console.log(
@ -850,7 +1552,7 @@ function filterRegionsByDifficulty(regions: MapRegion[], level: DifficultyLevel)
return filtered
}
// Use percentile filtering
// 3. Legacy percentile filtering
const percentile = level.keepPercentile ?? 1.0
if (percentile >= 1.0) {
return regions // Include all regions
@ -933,7 +1635,7 @@ export async function getFilteredMapData(
}
// Apply difficulty filtering
filteredRegions = filterRegionsByDifficulty(filteredRegions, level)
filteredRegions = filterRegionsByDifficulty(filteredRegions, level, mapId)
return {
...mapData,
@ -944,6 +1646,84 @@ export async function getFilteredMapData(
}
}
/**
* Get filtered map data by size categories (async - for server-side)
* This is the new clean API that takes sizes directly
*/
export async function getFilteredMapDataBySizes(
mapId: 'world' | 'usa',
continentId: ContinentId | 'all',
includeSizes: RegionSize[]
): Promise<MapData> {
const mapData = await getMapData(mapId)
let filteredRegions = mapData.regions
let adjustedViewBox = mapData.viewBox
let customCrop: string | null = null
// Apply continent filtering for world map
if (mapId === 'world' && continentId !== 'all') {
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
customCrop = getCustomCrop(mapId, continentId)
adjustedViewBox = calculateContinentViewBox(
mapData.regions,
continentId,
mapData.viewBox,
mapId
)
}
// Apply size filtering
filteredRegions = filterRegionsBySizes(filteredRegions, includeSizes, mapId)
return {
...mapData,
regions: filteredRegions,
viewBox: adjustedViewBox,
originalViewBox: mapData.viewBox,
customCrop,
}
}
/**
* Get filtered map data by size categories synchronously (for client components)
* This is the new clean API that takes sizes directly
*/
export function getFilteredMapDataBySizesSync(
mapId: 'world' | 'usa',
continentId: ContinentId | 'all',
includeSizes: RegionSize[]
): MapData {
const mapData = mapId === 'world' ? WORLD_MAP : USA_MAP
let filteredRegions = mapData.regions
let adjustedViewBox = mapData.viewBox
let customCrop: string | null = null
// Apply continent filtering for world map
if (mapId === 'world' && continentId !== 'all') {
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
customCrop = getCustomCrop(mapId, continentId)
adjustedViewBox = calculateContinentViewBox(
mapData.regions,
continentId,
mapData.viewBox,
mapId
)
}
// Apply size filtering
filteredRegions = filterRegionsBySizes(filteredRegions, includeSizes, mapId)
return {
...mapData,
regions: filteredRegions,
viewBox: adjustedViewBox,
originalViewBox: mapData.viewBox,
customCrop,
}
}
/**
* Sub-map registry: maps country/region IDs to their detailed sub-maps
* For drill-down navigation in the map selector
@ -1060,7 +1840,7 @@ export function getFilteredMapDataSync(
}
// Apply difficulty filtering
filteredRegions = filterRegionsByDifficulty(filteredRegions, level)
filteredRegions = filterRegionsByDifficulty(filteredRegions, level, mapId)
return {
...mapData,

View File

@ -1,12 +1,23 @@
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
import type { ContinentId } from './continents'
import type { MapDifficultyConfig } from './maps'
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'
// Game configuration (persisted to database)
export interface KnowYourWorldConfig extends GameConfig {
selectedMap: 'world' | 'usa'
gameMode: 'cooperative' | 'race' | 'turn-based'
difficulty: string // Difficulty level ID (e.g., 'easy', 'medium', 'hard', 'standard')
// Region filtering (which regions to include by size)
includeSizes: RegionSize[] // e.g., ['huge', 'large'] for major regions only
// Assistance level (gameplay features)
assistanceLevel: AssistanceLevel
// Legacy field - kept for backwards compatibility
difficulty?: string // @deprecated Use includeSizes + assistanceLevel instead
studyDuration: 0 | 30 | 60 | 120 // seconds (0 = skip study mode)
selectedContinent: ContinentId | 'all' // continent filter for world map ('all' = no filter)
}
@ -46,7 +57,12 @@ export interface KnowYourWorldState extends GameState {
// Setup configuration
selectedMap: 'world' | 'usa'
gameMode: 'cooperative' | 'race' | 'turn-based'
difficulty: string // Difficulty level ID (e.g., 'easy', 'medium', 'hard', 'standard')
// Region filtering (which regions to include by size)
includeSizes: RegionSize[] // e.g., ['huge', 'large'] for major regions only
// Assistance level (gameplay features)
assistanceLevel: AssistanceLevel
// Legacy field - kept for backwards compatibility during migration
difficulty?: string // @deprecated Use includeSizes + assistanceLevel instead
studyDuration: 0 | 30 | 60 | 120 // seconds (0 = skip study mode)
selectedContinent: ContinentId | 'all' // continent filter for world map ('all' = no filter)
@ -84,6 +100,13 @@ export interface KnowYourWorldState extends GameState {
// Unanimous give-up voting (for cooperative multiplayer)
giveUpVotes: string[] // Session/viewer IDs (userIds) who have voted to give up on current prompt
// Hint system
hintsUsed: number // Total hints used this game
hintActive: {
regionId: string
timestamp: number // For animation timing
} | null
}
// Move types
@ -98,7 +121,8 @@ export type KnowYourWorldMove =
playerMetadata: Record<string, any>
selectedMap: 'world' | 'usa'
gameMode: 'cooperative' | 'race' | 'turn-based'
difficulty: string // Difficulty level ID
includeSizes: RegionSize[] // Which region sizes to include
assistanceLevel: AssistanceLevel // Gameplay assistance level
}
}
| {
@ -144,12 +168,21 @@ export type KnowYourWorldMove =
}
}
| {
type: 'SET_DIFFICULTY'
type: 'SET_REGION_SIZES'
playerId: string
userId: string
timestamp: number
data: {
difficulty: string // Difficulty level ID
includeSizes: RegionSize[] // Which region sizes to include
}
}
| {
type: 'SET_ASSISTANCE_LEVEL'
playerId: string
userId: string
timestamp: number
data: {
assistanceLevel: AssistanceLevel
}
}
| {
@ -191,3 +224,10 @@ export type KnowYourWorldMove =
timestamp: number
data: {}
}
| {
type: 'REQUEST_HINT'
playerId: string
userId: string
timestamp: number
data: {}
}

View File

@ -487,10 +487,7 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
foundGoodZoom = true
acceptedRegionId = detectedRegion.id
// Log when we accept a zoom
console.log(
`[Zoom] ✅ Accepted ${testZoom.toFixed(1)}x for ${detectedRegion.id} (${currentWidth.toFixed(1)}px × ${currentHeight.toFixed(1)}px)`
)
// Zoom accepted (log removed to reduce spam)
// Mark this region's bounding box as accepted
const acceptedBox = boundingBoxes.find((bbox) => bbox.regionId === detectedRegion.id)

View File

@ -183,7 +183,8 @@ export const DEFAULT_YIJS_DEMO_CONFIG: YjsDemoGameConfig = {
export const DEFAULT_KNOW_YOUR_WORLD_CONFIG: KnowYourWorldConfig = {
selectedMap: 'world',
gameMode: 'cooperative',
difficulty: 'easy',
includeSizes: ['huge', 'large', 'medium'],
assistanceLevel: 'helpful',
studyDuration: 0,
selectedContinent: 'all',
}