feat: migrate contexts to UUID-based player system
GameModeContext: - Use Map<string, Player> instead of array - Use Set<string> for active player tracking - Add migration support on initialization - Remove hardcoded 1-4 player limit UserProfileContext: - Remove player1-4 fields (moved to GameModeContext) - Keep only game statistics - Add stats migration from V1
This commit is contained in:
@@ -1,111 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { Player, PlayerStorageV2, getNextPlayerColor } from '../types/player'
|
||||
import { loadPlayerStorage, savePlayerStorage } from '../lib/playerMigration'
|
||||
|
||||
export type GameMode = 'single' | 'battle' | 'tournament'
|
||||
|
||||
export interface PlayerConfig {
|
||||
id: number
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface GameModeContextType {
|
||||
gameMode: GameMode // Computed from activePlayerCount
|
||||
players: PlayerConfig[]
|
||||
players: Map<string, Player>
|
||||
activePlayers: Set<string>
|
||||
activePlayerCount: number
|
||||
updatePlayer: (id: number, config: Partial<PlayerConfig>) => void
|
||||
getActivePlayer: (id: number) => PlayerConfig | undefined
|
||||
addPlayer: (player?: Partial<Player>) => string
|
||||
updatePlayer: (id: string, updates: Partial<Player>) => void
|
||||
removePlayer: (id: string) => void
|
||||
setActive: (id: string, active: boolean) => void
|
||||
getActivePlayers: () => Player[]
|
||||
getPlayer: (id: string) => Player | undefined
|
||||
getAllPlayers: () => Player[]
|
||||
resetPlayers: () => void
|
||||
}
|
||||
|
||||
const defaultPlayers: PlayerConfig[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Player 1',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6', // Blue
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Player 2',
|
||||
emoji: '😎',
|
||||
color: '#8b5cf6', // Purple
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Player 3',
|
||||
emoji: '🤠',
|
||||
color: '#10b981', // Green
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Player 4',
|
||||
emoji: '🚀',
|
||||
color: '#f59e0b', // Orange
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'soroban-game-mode-players'
|
||||
|
||||
const GameModeContext = createContext<GameModeContextType | null>(null)
|
||||
|
||||
// Create initial default players
|
||||
function createDefaultPlayers(): Map<string, Player> {
|
||||
const players = new Map<string, Player>()
|
||||
const defaultData = [
|
||||
{ name: 'Player 1', emoji: '😀', color: '#3b82f6' },
|
||||
{ name: 'Player 2', emoji: '😎', color: '#8b5cf6' },
|
||||
{ name: 'Player 3', emoji: '🤠', color: '#10b981' },
|
||||
{ name: 'Player 4', emoji: '🚀', color: '#f59e0b' },
|
||||
]
|
||||
|
||||
defaultData.forEach((data) => {
|
||||
const id = nanoid()
|
||||
players.set(id, {
|
||||
id,
|
||||
...data,
|
||||
createdAt: Date.now(),
|
||||
isLocal: true,
|
||||
})
|
||||
})
|
||||
|
||||
return players
|
||||
}
|
||||
|
||||
export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const [players, setPlayers] = useState<PlayerConfig[]>(defaultPlayers)
|
||||
const [players, setPlayers] = useState<Map<string, Player>>(new Map())
|
||||
const [activePlayers, setActivePlayers] = useState<Set<string>>(new Set())
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Load configuration from localStorage on mount
|
||||
// Load from storage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const config = JSON.parse(stored)
|
||||
setPlayers(config.players || defaultPlayers)
|
||||
const storage = loadPlayerStorage()
|
||||
|
||||
if (storage) {
|
||||
// Load from V2 storage
|
||||
const playerMap = new Map<string, Player>()
|
||||
Object.values(storage.players).forEach(player => {
|
||||
playerMap.set(player.id, player)
|
||||
})
|
||||
|
||||
setPlayers(playerMap)
|
||||
setActivePlayers(new Set(storage.activePlayerIds))
|
||||
console.log('✅ Loaded player storage (V2)', {
|
||||
playerCount: playerMap.size,
|
||||
activeCount: storage.activePlayerIds.length,
|
||||
})
|
||||
} else {
|
||||
// No storage, create defaults
|
||||
const defaultPlayers = createDefaultPlayers()
|
||||
const firstPlayerId = Array.from(defaultPlayers.keys())[0]
|
||||
|
||||
setPlayers(defaultPlayers)
|
||||
setActivePlayers(new Set([firstPlayerId]))
|
||||
console.log('✅ Created default players', {
|
||||
playerCount: defaultPlayers.size,
|
||||
})
|
||||
}
|
||||
|
||||
setIsInitialized(true)
|
||||
} catch (error) {
|
||||
console.warn('Failed to load game mode config from localStorage:', error)
|
||||
console.error('Failed to load player storage:', error)
|
||||
// Fallback to defaults
|
||||
const defaultPlayers = createDefaultPlayers()
|
||||
const firstPlayerId = Array.from(defaultPlayers.keys())[0]
|
||||
setPlayers(defaultPlayers)
|
||||
setActivePlayers(new Set([firstPlayerId]))
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save configuration to localStorage whenever it changes
|
||||
// Save to storage whenever players or active players change
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && isInitialized) {
|
||||
try {
|
||||
const config = { players }
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
const storage: PlayerStorageV2 = {
|
||||
version: 2,
|
||||
players: Object.fromEntries(players),
|
||||
activePlayerIds: Array.from(activePlayers),
|
||||
activationOrder: Object.fromEntries(
|
||||
Array.from(activePlayers).map((id, idx) => [id, idx])
|
||||
),
|
||||
}
|
||||
|
||||
savePlayerStorage(storage)
|
||||
} catch (error) {
|
||||
console.warn('Failed to save game mode config to localStorage:', error)
|
||||
console.error('Failed to save player storage:', error)
|
||||
}
|
||||
}
|
||||
}, [players, isInitialized])
|
||||
}, [players, activePlayers, isInitialized])
|
||||
|
||||
const updatePlayer = (id: number, config: Partial<PlayerConfig>) => {
|
||||
setPlayers(prevPlayers =>
|
||||
prevPlayers.map(player =>
|
||||
player.id === id ? { ...player, ...config } : player
|
||||
)
|
||||
)
|
||||
const addPlayer = (playerData?: Partial<Player>): string => {
|
||||
const id = nanoid()
|
||||
const playerList = Array.from(players.values())
|
||||
|
||||
const newPlayer: Player = {
|
||||
id,
|
||||
name: playerData?.name ?? `Player ${players.size + 1}`,
|
||||
emoji: playerData?.emoji ?? '🎮',
|
||||
color: playerData?.color ?? getNextPlayerColor(playerList),
|
||||
createdAt: playerData?.createdAt ?? Date.now(),
|
||||
isLocal: playerData?.isLocal ?? true,
|
||||
...playerData,
|
||||
}
|
||||
|
||||
setPlayers(prev => new Map(prev).set(id, newPlayer))
|
||||
return id
|
||||
}
|
||||
|
||||
const getActivePlayer = (id: number) => {
|
||||
return players.find(player => player.id === id && player.isActive)
|
||||
const updatePlayer = (id: string, updates: Partial<Player>) => {
|
||||
setPlayers(prev => {
|
||||
const newMap = new Map(prev)
|
||||
const existing = newMap.get(id)
|
||||
if (existing) {
|
||||
newMap.set(id, { ...existing, ...updates })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
}
|
||||
|
||||
const removePlayer = (id: string) => {
|
||||
setPlayers(prev => {
|
||||
const newMap = new Map(prev)
|
||||
newMap.delete(id)
|
||||
return newMap
|
||||
})
|
||||
|
||||
// Also remove from active if was active
|
||||
setActivePlayers(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(id)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const setActive = (id: string, active: boolean) => {
|
||||
setActivePlayers(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (active) {
|
||||
newSet.add(id)
|
||||
} else {
|
||||
newSet.delete(id)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const getActivePlayers = (): Player[] => {
|
||||
return Array.from(activePlayers)
|
||||
.map(id => players.get(id))
|
||||
.filter((p): p is Player => p !== undefined)
|
||||
}
|
||||
|
||||
const getPlayer = (id: string): Player | undefined => {
|
||||
return players.get(id)
|
||||
}
|
||||
|
||||
const getAllPlayers = (): Player[] => {
|
||||
return Array.from(players.values())
|
||||
}
|
||||
|
||||
const resetPlayers = () => {
|
||||
const defaultPlayers = createDefaultPlayers()
|
||||
const firstPlayerId = Array.from(defaultPlayers.keys())[0]
|
||||
|
||||
setPlayers(defaultPlayers)
|
||||
setActivePlayers(new Set([firstPlayerId]))
|
||||
}
|
||||
|
||||
const activePlayerCount = players.filter(player => player.isActive).length
|
||||
const activePlayerCount = activePlayers.size
|
||||
|
||||
// Compute game mode from active player count
|
||||
const gameMode: GameMode = activePlayerCount === 1 ? 'single' :
|
||||
@@ -115,10 +204,16 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const contextValue: GameModeContextType = {
|
||||
gameMode,
|
||||
players,
|
||||
activePlayers,
|
||||
activePlayerCount,
|
||||
addPlayer,
|
||||
updatePlayer,
|
||||
getActivePlayer,
|
||||
resetPlayers
|
||||
removePlayer,
|
||||
setActive,
|
||||
getActivePlayers,
|
||||
getPlayer,
|
||||
getAllPlayers,
|
||||
resetPlayers,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -134,4 +229,4 @@ export function useGameMode(): GameModeContextType {
|
||||
throw new Error('useGameMode must be used within a GameModeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
|
||||
// Available character emojis for players (deduplicated)
|
||||
export const PLAYER_EMOJIS = [
|
||||
// People & Characters
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '😉', '😊', '😇',
|
||||
'🥰', '😍', '🤩', '😘', '😗', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨',
|
||||
'🧐', '🤓', '😎', '🥸', '🥳', '😏', '😒', '😞', '😔', '😟', '😕',
|
||||
|
||||
// Fantasy & Fun
|
||||
'🤠', '🥷', '👑', '🎭', '🤖', '👻', '💀', '👽', '🤡', '🧙♂️', '🧙♀️', '🧚♂️',
|
||||
'🧚♀️', '🧛♂️', '🧛♀️', '🧜♂️', '🧜♀️', '🧝♂️', '🧝♀️', '🦸♂️', '🦸♀️', '🦹♂️',
|
||||
|
||||
// Animals
|
||||
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐻❄️', '🐨', '🐯', '🦁',
|
||||
'🐮', '🐷', '🐸', '🐵', '🙈', '🙉', '🙊', '🐒', '🦆', '🐧', '🐦', '🐤',
|
||||
'🐣', '🐥', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛', '🦋',
|
||||
|
||||
// Objects & Symbols
|
||||
'⭐', '🌟', '💫', '✨', '⚡', '🔥', '🌈', '🎪', '🎨', '🎯',
|
||||
'🎲', '🎮', '🕹️', '🎸', '🎺', '🎷', '🥁', '🎻', '🎤', '🎧', '🎬', '🎥',
|
||||
|
||||
// Food & Drinks
|
||||
'🍎', '🍊', '🍌', '🍇', '🍓', '🥝', '🍑', '🥭', '🍍', '🥥', '🥑', '🍆',
|
||||
'🥕', '🌽', '🌶️', '🫑', '🥒', '🥬', '🥦', '🧄', '🧅', '🍄', '🥜', '🌰'
|
||||
]
|
||||
|
||||
export interface UserProfile {
|
||||
player1Emoji: string
|
||||
player2Emoji: string
|
||||
player3Emoji: string
|
||||
player4Emoji: string
|
||||
player1Name: string
|
||||
player2Name: string
|
||||
player3Name: string
|
||||
player4Name: string
|
||||
gamesPlayed: number
|
||||
totalWins: number
|
||||
favoriteGameType: 'abacus-numeral' | 'complement-pairs' | null
|
||||
bestTime: number | null
|
||||
highestAccuracy: number
|
||||
}
|
||||
import { UserStatsProfile } from '../types/player'
|
||||
import { STORAGE_KEY_STATS, STORAGE_KEY_V1, extractStatsFromV1 } from '../lib/playerMigration'
|
||||
|
||||
export interface UserProfileContextType {
|
||||
profile: UserProfile
|
||||
updatePlayerEmoji: (player: 1 | 2 | 3 | 4, emoji: string) => void
|
||||
updatePlayerName: (player: 1 | 2 | 3 | 4, name: string) => void
|
||||
updateGameStats: (stats: Partial<Pick<UserProfile, 'gamesPlayed' | 'totalWins' | 'favoriteGameType' | 'bestTime' | 'highestAccuracy'>>) => void
|
||||
profile: UserStatsProfile
|
||||
updateGameStats: (stats: Partial<UserStatsProfile>) => void
|
||||
resetProfile: () => void
|
||||
}
|
||||
|
||||
const defaultProfile: UserProfile = {
|
||||
player1Emoji: '😀',
|
||||
player2Emoji: '😎',
|
||||
player3Emoji: '🤓',
|
||||
player4Emoji: '🥳',
|
||||
player1Name: 'Player 1',
|
||||
player2Name: 'Player 2',
|
||||
player3Name: 'Player 3',
|
||||
player4Name: 'Player 4',
|
||||
const defaultProfile: UserStatsProfile = {
|
||||
gamesPlayed: 0,
|
||||
totalWins: 0,
|
||||
favoriteGameType: null,
|
||||
@@ -67,28 +18,40 @@ const defaultProfile: UserProfile = {
|
||||
highestAccuracy: 0
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'soroban-memory-pairs-profile'
|
||||
|
||||
const UserProfileContext = createContext<UserProfileContextType | null>(null)
|
||||
|
||||
export function UserProfileProvider({ children }: { children: ReactNode }) {
|
||||
const [profile, setProfile] = useState<UserProfile>(defaultProfile)
|
||||
const [profile, setProfile] = useState<UserStatsProfile>(defaultProfile)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Load profile from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
// Try to load from new stats storage
|
||||
let stored = localStorage.getItem(STORAGE_KEY_STATS)
|
||||
|
||||
if (!stored) {
|
||||
// Check for V1 data to migrate stats from
|
||||
const v1Data = localStorage.getItem(STORAGE_KEY_V1)
|
||||
if (v1Data) {
|
||||
const v1Profile = JSON.parse(v1Data)
|
||||
const migratedStats = extractStatsFromV1(v1Profile)
|
||||
setProfile(migratedStats)
|
||||
// Save to new location
|
||||
localStorage.setItem(STORAGE_KEY_STATS, JSON.stringify(migratedStats))
|
||||
console.log('✅ Migrated stats from V1 to new storage')
|
||||
}
|
||||
} else {
|
||||
const parsedProfile = JSON.parse(stored)
|
||||
// Merge with defaults to handle new fields
|
||||
const mergedProfile = { ...defaultProfile, ...parsedProfile }
|
||||
setProfile(mergedProfile)
|
||||
}
|
||||
|
||||
setIsInitialized(true)
|
||||
} catch (error) {
|
||||
console.warn('Failed to load user profile from localStorage:', error)
|
||||
console.warn('Failed to load user stats from localStorage:', error)
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}
|
||||
@@ -98,28 +61,14 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && isInitialized) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(profile))
|
||||
localStorage.setItem(STORAGE_KEY_STATS, JSON.stringify(profile))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save user profile to localStorage:', error)
|
||||
console.warn('Failed to save user stats to localStorage:', error)
|
||||
}
|
||||
}
|
||||
}, [profile, isInitialized])
|
||||
|
||||
const updatePlayerEmoji = (player: 1 | 2, emoji: string) => {
|
||||
setProfile(prev => ({
|
||||
...prev,
|
||||
[`player${player}Emoji`]: emoji
|
||||
}))
|
||||
}
|
||||
|
||||
const updatePlayerName = (player: 1 | 2, name: string) => {
|
||||
setProfile(prev => ({
|
||||
...prev,
|
||||
[`player${player}Name`]: name
|
||||
}))
|
||||
}
|
||||
|
||||
const updateGameStats = (stats: Partial<Pick<UserProfile, 'gamesPlayed' | 'totalWins' | 'favoriteGameType' | 'bestTime' | 'highestAccuracy'>>) => {
|
||||
const updateGameStats = (stats: Partial<UserStatsProfile>) => {
|
||||
setProfile(prev => ({
|
||||
...prev,
|
||||
...stats
|
||||
@@ -132,8 +81,6 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const contextValue: UserProfileContextType = {
|
||||
profile,
|
||||
updatePlayerEmoji,
|
||||
updatePlayerName,
|
||||
updateGameStats,
|
||||
resetProfile
|
||||
}
|
||||
@@ -151,4 +98,4 @@ export function useUserProfile(): UserProfileContextType {
|
||||
throw new Error('useUserProfile must be used within a UserProfileProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user