From 2b94cad11bd05b1a324e360c56be686c3c6a4b64 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 4 Oct 2025 17:06:53 -0500 Subject: [PATCH] feat: migrate contexts to UUID-based player system GameModeContext: - Use Map instead of array - Use Set 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 --- apps/web/src/contexts/GameModeContext.tsx | 231 +++++++++++++------ apps/web/src/contexts/UserProfileContext.tsx | 107 +++------ 2 files changed, 190 insertions(+), 148 deletions(-) diff --git a/apps/web/src/contexts/GameModeContext.tsx b/apps/web/src/contexts/GameModeContext.tsx index e63b5ba0..a72e3065 100644 --- a/apps/web/src/contexts/GameModeContext.tsx +++ b/apps/web/src/contexts/GameModeContext.tsx @@ -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 + activePlayers: Set activePlayerCount: number - updatePlayer: (id: number, config: Partial) => void - getActivePlayer: (id: number) => PlayerConfig | undefined + addPlayer: (player?: Partial) => string + updatePlayer: (id: string, updates: Partial) => 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(null) +// Create initial default players +function createDefaultPlayers(): Map { + const players = new Map() + 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(defaultPlayers) + const [players, setPlayers] = useState>(new Map()) + const [activePlayers, setActivePlayers] = useState>(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() + 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) => { - setPlayers(prevPlayers => - prevPlayers.map(player => - player.id === id ? { ...player, ...config } : player - ) - ) + const addPlayer = (playerData?: Partial): 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) => { + 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 -} \ No newline at end of file +} diff --git a/apps/web/src/contexts/UserProfileContext.tsx b/apps/web/src/contexts/UserProfileContext.tsx index 7c6e9294..e4846b91 100644 --- a/apps/web/src/contexts/UserProfileContext.tsx +++ b/apps/web/src/contexts/UserProfileContext.tsx @@ -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>) => void + profile: UserStatsProfile + updateGameStats: (stats: Partial) => 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(null) export function UserProfileProvider({ children }: { children: ReactNode }) { - const [profile, setProfile] = useState(defaultProfile) + const [profile, setProfile] = useState(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>) => { + const updateGameStats = (stats: Partial) => { 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 -} \ No newline at end of file +}