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:
Thomas Hallock
2025-10-04 17:06:53 -05:00
parent 79f44b25d6
commit 2b94cad11b
2 changed files with 190 additions and 148 deletions

View File

@@ -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
}
}

View File

@@ -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
}
}