feat: add player types and migration utilities

- Add Player interface with UUID-based id field
- Add PlayerStorageV2 format for new storage schema
- Add migration utilities to convert from V1 (indexed) to V2 (UUID)
- Add validation and rollback support
- Move PLAYER_EMOJIS to shared constants
This commit is contained in:
Thomas Hallock 2025-10-04 17:06:53 -05:00
parent 42b73cf8ee
commit 79f44b25d6
3 changed files with 365 additions and 0 deletions

View File

@ -0,0 +1,24 @@
// Available character emojis for players
export const PLAYER_EMOJIS = [
// People & Characters
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '😉', '😊', '😇',
'🥰', '😍', '🤩', '😘', '😗', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨',
'🧐', '🤓', '😎', '🥸', '🥳', '😏', '😒', '😞', '😔', '😟', '😕',
// Fantasy & Fun
'🤠', '🥷', '👑', '🎭', '🤖', '👻', '💀', '👽', '🤡', '🧙‍♂️', '🧙‍♀️', '🧚‍♂️',
'🧚‍♀️', '🧛‍♂️', '🧛‍♀️', '🧜‍♂️', '🧜‍♀️', '🧝‍♂️', '🧝‍♀️', '🦸‍♂️', '🦸‍♀️', '🦹‍♂️',
// Animals
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐻‍❄️', '🐨', '🐯', '🦁',
'🐮', '🐷', '🐸', '🐵', '🙈', '🙉', '🙊', '🐒', '🦆', '🐧', '🐦', '🐤',
'🐣', '🐥', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛', '🦋',
// Objects & Symbols
'⭐', '🌟', '💫', '✨', '⚡', '🔥', '🌈', '🎪', '🎨', '🎯',
'🎲', '🎮', '🕹️', '🎸', '🎺', '🎷', '🥁', '🎻', '🎤', '🎧', '🎬', '🎥',
// Food & Drinks
'🍎', '🍊', '🍌', '🍇', '🍓', '🥝', '🍑', '🥭', '🍍', '🥥', '🥑', '🍆',
'🥕', '🌽', '🌶️', '🫑', '🥒', '🥬', '🥦', '🧄', '🧅', '🍄', '🥜', '🌰'
]

View File

@ -0,0 +1,230 @@
import { nanoid } from 'nanoid'
import {
Player,
PlayerStorageV2,
UserProfileV1,
UserStatsProfile,
DEFAULT_PLAYER_COLORS,
} from '../types/player'
// Storage keys
export const STORAGE_KEY_V1 = 'soroban-memory-pairs-profile'
export const STORAGE_KEY_V2 = 'soroban-players-v2'
export const STORAGE_KEY_STATS = 'soroban-user-stats'
/**
* Migrate from V1 (indexed players) to V2 (UUID-based players)
*/
export function migrateFromV1(v1Profile: UserProfileV1): PlayerStorageV2 {
const players: Record<string, Player> = {}
const activePlayerIds: string[] = []
const activationOrder: Record<string, number> = {}
// Migrate each indexed player (1-4)
for (let i = 1; i <= 4; i++) {
const id = nanoid()
const createdAt = Date.now() - (4 - i) * 1000 // Preserve order (older = lower index)
players[id] = {
id,
name: v1Profile[`player${i}Name` as keyof UserProfileV1] as string,
emoji: v1Profile[`player${i}Emoji` as keyof UserProfileV1] as string,
color: DEFAULT_PLAYER_COLORS[i - 1],
createdAt,
isLocal: true,
}
// First player active by default (matches old behavior)
if (i === 1) {
activePlayerIds.push(id)
activationOrder[id] = 0
}
}
return {
version: 2,
players,
activePlayerIds,
activationOrder,
}
}
/**
* Extract stats from V1 profile (player data removed)
*/
export function extractStatsFromV1(v1Profile: UserProfileV1): UserStatsProfile {
return {
gamesPlayed: v1Profile.gamesPlayed,
totalWins: v1Profile.totalWins,
favoriteGameType: v1Profile.favoriteGameType,
bestTime: v1Profile.bestTime,
highestAccuracy: v1Profile.highestAccuracy,
}
}
/**
* Check if migration is needed
*/
export function needsMigration(): boolean {
if (typeof window === 'undefined') return false
const hasV1 = localStorage.getItem(STORAGE_KEY_V1) !== null
const hasV2 = localStorage.getItem(STORAGE_KEY_V2) !== null
return hasV1 && !hasV2
}
/**
* Perform migration from V1 to V2
* Returns migrated data or null if migration not needed
*/
export function performMigration(): {
players: PlayerStorageV2
stats: UserStatsProfile
} | null {
if (typeof window === 'undefined') return null
if (!needsMigration()) return null
try {
const v1Json = localStorage.getItem(STORAGE_KEY_V1)
if (!v1Json) return null
const v1Profile = JSON.parse(v1Json) as UserProfileV1
// Migrate to V2
const v2Storage = migrateFromV1(v1Profile)
const stats = extractStatsFromV1(v1Profile)
// Validate migration
if (!validateMigration(v1Profile, v2Storage)) {
console.error('Migration validation failed')
return null
}
// Save to new storage keys
localStorage.setItem(STORAGE_KEY_V2, JSON.stringify(v2Storage))
localStorage.setItem(STORAGE_KEY_STATS, JSON.stringify(stats))
// Keep V1 for rollback (don't delete yet)
console.log('✅ Successfully migrated from V1 to V2 player storage')
return { players: v2Storage, stats }
} catch (error) {
console.error('Migration failed:', error)
return null
}
}
/**
* Validate that migration preserved all data
*/
export function validateMigration(
v1: UserProfileV1,
v2: PlayerStorageV2
): boolean {
// Should have migrated all 4 players
const playerCount = Object.keys(v2.players).length
if (playerCount !== 4) {
console.error(`Expected 4 players, got ${playerCount}`)
return false
}
// Check that all player data is preserved
const v2Players = Object.values(v2.players)
// Verify all names migrated
const v1Names = [
v1.player1Name,
v1.player2Name,
v1.player3Name,
v1.player4Name,
]
const v2Names = v2Players.map(p => p.name)
for (const name of v1Names) {
if (!v2Names.includes(name)) {
console.error(`Missing player name: ${name}`)
return false
}
}
// Verify all emojis migrated
const v1Emojis = [
v1.player1Emoji,
v1.player2Emoji,
v1.player3Emoji,
v1.player4Emoji,
]
const v2Emojis = v2Players.map(p => p.emoji)
for (const emoji of v1Emojis) {
if (!v2Emojis.includes(emoji)) {
console.error(`Missing player emoji: ${emoji}`)
return false
}
}
// Should have exactly 1 active player (player 1)
if (v2.activePlayerIds.length !== 1) {
console.error(
`Expected 1 active player, got ${v2.activePlayerIds.length}`
)
return false
}
console.log('✅ Migration validation passed')
return true
}
/**
* Rollback to V1 (for emergency use)
*/
export function rollbackToV1(): void {
if (typeof window === 'undefined') return
const confirmed = confirm(
'Are you sure you want to rollback to the old player system? This will delete all new player data.'
)
if (!confirmed) return
localStorage.removeItem(STORAGE_KEY_V2)
localStorage.removeItem(STORAGE_KEY_STATS)
console.log('⚠️ Rolled back to V1 storage')
window.location.reload()
}
/**
* Load V2 storage or perform migration if needed
*/
export function loadPlayerStorage(): PlayerStorageV2 | null {
if (typeof window === 'undefined') return null
// Try to load V2
const v2Json = localStorage.getItem(STORAGE_KEY_V2)
if (v2Json) {
try {
return JSON.parse(v2Json) as PlayerStorageV2
} catch (error) {
console.error('Failed to parse V2 storage:', error)
}
}
// No V2, check if migration needed
const migrationResult = performMigration()
return migrationResult?.players ?? null
}
/**
* Save V2 storage
*/
export function savePlayerStorage(storage: PlayerStorageV2): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY_V2, JSON.stringify(storage))
} catch (error) {
console.error('Failed to save player storage:', error)
}
}

View File

@ -0,0 +1,111 @@
/**
* Player types for the new UUID-based player system
* This replaces the old index-based (1-4) player system
*/
export interface Player {
/** Globally unique identifier (nanoid) */
id: string
/** Display name */
name: string
/** Player emoji/avatar */
emoji: string
/** Player color (for UI theming) */
color: string
/** Creation timestamp */
createdAt: number
/** Optional: Device ID for multi-device sync */
deviceId?: string
/** Optional: Last sync timestamp */
syncedAt?: number
/** Optional: Whether this is a local or remote player */
isLocal?: boolean
}
/**
* Storage format V2
* Replaces the old UserProfile format with indexed players
*/
export interface PlayerStorageV2 {
version: 2
/** All known players, keyed by ID */
players: Record<string, Player>
/** IDs of currently active players (order matters) */
activePlayerIds: string[]
/** Activation order mapping for sorting */
activationOrder: Record<string, number>
}
/**
* Legacy storage format (V1)
* Used for migration from old system
*/
export interface UserProfileV1 {
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
}
/**
* New stats-only profile (player data moved to PlayerStorageV2)
*/
export interface UserStatsProfile {
gamesPlayed: number
totalWins: number
favoriteGameType: 'abacus-numeral' | 'complement-pairs' | null
bestTime: number | null
highestAccuracy: number
}
/**
* Default player colors (used during migration and player creation)
*/
export const DEFAULT_PLAYER_COLORS = [
'#3b82f6', // Blue
'#8b5cf6', // Purple
'#10b981', // Green
'#f59e0b', // Orange
'#ef4444', // Red
'#14b8a6', // Teal
'#f97316', // Deep Orange
'#6366f1', // Indigo
'#ec4899', // Pink
'#84cc16', // Lime
]
/**
* Get a color for a new player (cycles through defaults)
*/
export function getNextPlayerColor(existingPlayers: Player[]): string {
const usedColors = new Set(existingPlayers.map(p => p.color))
// Find first unused color
for (const color of DEFAULT_PLAYER_COLORS) {
if (!usedColors.has(color)) {
return color
}
}
// If all colors used, cycle back
return DEFAULT_PLAYER_COLORS[existingPlayers.length % DEFAULT_PLAYER_COLORS.length]
}