fix: use player IDs instead of array indices in matching game

Changed Player type from number to string (UUID) throughout the
matching game to properly identify players by their unique IDs
rather than array positions. This fixes the "Not your turn"
validation errors that were occurring because server-side
validation was comparing UUIDs (move.playerId) with numeric
indices (state.currentPlayer).

Changes:
- Updated Player type from number to string in both arcade and
  games matching context types
- Changed all player tracking to use UUID strings instead of
  numeric indices (1, 2, 3)
- Updated turn validation in MatchingGameValidator to compare
  string IDs correctly
- Fixed all UI components (GameCard, PlayerStatusBar, etc.) to
  use player.findIndex() for array positions when needed
- Updated MatchingStartGameMove type to expect string[] for
  activePlayers
- Re-enabled turn validation (previously disabled as workaround)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-06 12:49:10 -05:00
parent 619be9859c
commit ccd0d6d94c
18 changed files with 188 additions and 134 deletions

View File

@@ -15,6 +15,12 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
.map(id => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Helper to get player index from ID (0-based)
const getPlayerIndex = (playerId: string | undefined): number => {
if (!playerId) return -1
return activePlayers.findIndex(p => p.id === playerId)
}
const cardBackStyles = css({
position: 'absolute',
width: '100%',
@@ -53,13 +59,14 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
// Dynamic styling based on card type and state
const getCardBackGradient = () => {
if (isMatched) {
// Player-specific colors for matched cards
if (card.matchedBy === 1) {
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for player 1
} else if (card.matchedBy === 2) {
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for player 2
// Player-specific colors for matched cards - find player index by ID
const playerIndex = getPlayerIndex(card.matchedBy)
if (playerIndex === 0) {
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for first player
} else if (playerIndex === 1) {
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for second player
}
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player or 3+
}
switch (card.type) {
@@ -77,8 +84,9 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
const getCardBackIcon = () => {
if (isMatched) {
// Show player emoji for matched cards in multiplayer mode
if (card.matchedBy && card.matchedBy <= activePlayers.length) {
return activePlayers[card.matchedBy - 1]?.emoji || '✓'
if (card.matchedBy) {
const matchedPlayer = activePlayers.find(p => p.id === card.matchedBy)
return matchedPlayer?.emoji || '✓'
}
return '✓' // Default checkmark for single player
}
@@ -98,12 +106,13 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
const getBorderColor = () => {
if (isMatched) {
// Player-specific border colors for matched cards
if (card.matchedBy === 1) {
return '#74b9ff' // Blue for player 1
} else if (card.matchedBy === 2) {
return '#fd79a8' // Pink for player 2
const playerIndex = getPlayerIndex(card.matchedBy)
if (playerIndex === 0) {
return '#74b9ff' // Blue for first player
} else if (playerIndex === 1) {
return '#fd79a8' // Pink for second player
}
return '#48bb78' // Default green for single player
return '#48bb78' // Default green for single player or 3+
}
if (isFlipped) return '#667eea'
return '#e2e8f0'
@@ -164,9 +173,9 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
style={{
borderColor: getBorderColor(),
boxShadow: isMatched
? card.matchedBy === 1
? getPlayerIndex(card.matchedBy) === 0
? '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for player 1
: card.matchedBy === 2
: getPlayerIndex(card.matchedBy) === 1
? '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for player 2
: '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow
: isFlipped
@@ -186,7 +195,7 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
height: '32px',
borderRadius: '50%',
border: '3px solid',
borderColor: card.matchedBy === 1 ? '#74b9ff' : '#fd79a8',
borderColor: getPlayerIndex(card.matchedBy) === 0 ? '#74b9ff' : '#fd79a8',
animation: 'explosionRing 0.6s ease-out',
zIndex: 9
})} />
@@ -199,14 +208,14 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
width: '32px',
height: '32px',
borderRadius: '50%',
background: card.matchedBy === 1
background: getPlayerIndex(card.matchedBy) === 0
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
: 'linear-gradient(135deg, #fd79a8, #e84393)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
boxShadow: card.matchedBy === 1
boxShadow: getPlayerIndex(card.matchedBy) === 0
? '0 0 20px rgba(116, 185, 255, 0.6), 0 0 40px rgba(116, 185, 255, 0.4)'
: '0 0 20px rgba(253, 121, 168, 0.6), 0 0 40px rgba(253, 121, 168, 0.4)',
animation: 'epicClaim 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
@@ -219,7 +228,7 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
right: '-2px',
bottom: '-2px',
borderRadius: '50%',
background: card.matchedBy === 1
background: getPlayerIndex(card.matchedBy) === 0
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)',
animation: 'spinningHalo 2s linear infinite',
@@ -230,8 +239,8 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
animation: 'emojiBlast 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.4s both',
filter: 'drop-shadow(0 0 8px rgba(255,255,255,0.8))'
})}>
{card.matchedBy && card.matchedBy <= activePlayers.length
? activePlayers[card.matchedBy - 1]?.emoji || '✓'
{card.matchedBy
? activePlayers.find(p => p.id === card.matchedBy)?.emoji || '✓'
: '✓'}
</span>
</div>

View File

@@ -17,8 +17,9 @@ export function GamePhase() {
.map(id => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Map numeric player ID (1, 2, 3...) to actual player data
const currentPlayerData = activePlayersArray[state.currentPlayer - 1]
// Map player ID (UUID string) to actual player data using array index
const currentPlayerIndex = activePlayers.findIndex(id => id === state.currentPlayer)
const currentPlayerData = currentPlayerIndex >= 0 ? activePlayersArray[currentPlayerIndex] : undefined
const activePlayerData = activePlayersArray
return (

View File

@@ -1,8 +1,10 @@
'use client'
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { SetupPhase } from './SetupPhase'
import { GamePhase } from './GamePhase'
import { ResultsPhase } from './ResultsPhase'
@@ -11,8 +13,10 @@ import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../../styled-system/css'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession } = useArcadeMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -28,9 +32,10 @@ export function MemoryPairsGame() {
navTitle="Memory Pairs"
navEmoji="🧩"
emphasizeGameContext={state.gamePhase === 'setup'}
canModifyPlayers={canModifyPlayers}
onExitSession={() => {
exitSession()
window.location.reload()
router.push('/arcade')
}}
>
<StandardGameLayout>

View File

@@ -20,13 +20,13 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Map active players to display data with scores
// State uses numeric player IDs (1, 2, 3...), so we map by index
const activePlayers = activePlayersData.map((player, index) => ({
// State now uses player IDs (UUIDs) as keys
const activePlayers = activePlayersData.map((player) => ({
...player,
displayName: player.name,
displayEmoji: player.emoji,
score: state.scores[index + 1] || 0,
consecutiveMatches: state.consecutiveMatches?.[index + 1] || 0
score: state.scores[player.id] || 0,
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0
}))
// Get celebration level based on consecutive matches
@@ -101,8 +101,8 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
gap: { base: '2', md: '3' },
alignItems: 'center'
})}>
{activePlayers.map((player, index) => {
const isCurrentPlayer = (index + 1) === state.currentPlayer
{activePlayers.map((player) => {
const isCurrentPlayer = player.id === state.currentPlayer
const isLeading = player.score === Math.max(...activePlayers.map(p => p.score)) && player.score > 0
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)

View File

@@ -16,11 +16,10 @@ export function ResultsPhase() {
const activePlayerData = Array.from(activePlayerIds)
.map(id => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
.map((player, index) => ({
.map((player) => ({
...player,
displayName: player.name,
displayEmoji: player.emoji,
numericId: index + 1 // For compatibility with state.scores
displayEmoji: player.emoji
}))
const gameTime = state.gameEndTime && state.gameStartTime
@@ -78,7 +77,7 @@ export function ResultsPhase() {
color: 'blue.600',
fontWeight: 'bold'
})}>
🏆 {activePlayerData.find(p => p.numericId === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
🏆 {activePlayerData.find(p => p.id === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
</p>
) : (
<p className={css({
@@ -191,8 +190,8 @@ export function ResultsPhase() {
flexWrap: 'wrap'
})}>
{activePlayerData.map((player) => {
const score = multiplayerResult.scores[player.numericId] || 0
const isWinner = multiplayerResult.winners.includes(player.numericId)
const score = multiplayerResult.scores[player.id] || 0
const isWinner = multiplayerResult.winners.includes(player.id)
return (
<div key={player.id} className={css({

View File

@@ -21,7 +21,7 @@ const initialState: MemoryPairsState = {
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: 1,
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
@@ -54,10 +54,10 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: move.data.activePlayers.reduce((acc: any, p: number) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: move.data.activePlayers.reduce((acc: any, p: number) => ({ ...acc, [p]: 0 }), {}),
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
activePlayers: move.data.activePlayers,
currentPlayer: move.data.activePlayers[0] || 1,
currentPlayer: move.data.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
@@ -106,8 +106,8 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
const { data: viewerId } = useViewerId()
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
// Get active player IDs as numbers
const activePlayers = Array.from(activePlayerIds).map((id, index) => index + 1)
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'

View File

@@ -28,7 +28,7 @@ const initialState: MemoryPairsState = {
// Game progression
gamePhase: 'setup',
currentPlayer: 1,
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
@@ -76,7 +76,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
case 'START_GAME':
// Initialize scores and consecutive matches for all active players
const scores: PlayerScore = {}
const consecutiveMatches: { [playerId: number]: number } = {}
const consecutiveMatches: { [playerId: string]: number } = {}
action.activePlayers.forEach(playerId => {
scores[playerId] = 0
consecutiveMatches[playerId] = 0
@@ -93,7 +93,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
scores,
consecutiveMatches,
activePlayers: action.activePlayers,
currentPlayer: action.activePlayers[0] || 1,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
@@ -253,8 +253,8 @@ export function MemoryPairsProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(memoryPairsReducer, initialState)
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
// Get active player IDs as numbers (convert from string IDs for now to maintain compatibility)
const activePlayers = Array.from(activePlayerIds).map((id, index) => index + 1)
// Get active player IDs directly from GameModeContext
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
@@ -358,6 +358,7 @@ export function MemoryPairsProvider({ children }: { children: ReactNode }) {
resetGame,
setGameType,
setDifficulty,
exitSession: () => {}, // No-op for non-arcade mode
gameMode, // Expose derived gameMode
activePlayers // Expose active players
}

View File

@@ -5,7 +5,7 @@ export type GameType = 'abacus-numeral' | 'complement-pairs'
export type GamePhase = 'setup' | 'playing' | 'results'
export type CardType = 'abacus' | 'number' | 'complement'
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
export type Player = number // Now supports any player ID
export type Player = string // Player ID (UUID)
export type TargetSum = 5 | 10 | 20
export interface GameCard {
@@ -20,7 +20,7 @@ export interface GameCard {
}
export interface PlayerScore {
[playerId: number]: number
[playerId: string]: number
}
export interface CelebrationAnimation {
@@ -59,7 +59,7 @@ export interface MemoryPairsState {
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
consecutiveMatches: { [playerId: number]: number } // Track consecutive matches per player
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// Timing
gameStartTime: number | null

View File

@@ -251,33 +251,42 @@ export function formatGameTime(milliseconds: number): string {
}
// Get two-player game winner
export function getTwoPlayerWinner(state: MemoryPairsState): {
// @deprecated Use getMultiplayerWinner instead which supports N players
export function getTwoPlayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
winner: Player | 'tie'
winnerScore: number
loserScore: number
margin: number
} {
const { scores } = state
const [player1, player2] = activePlayers
if (scores[1] > scores[2]) {
if (!player1 || !player2) {
throw new Error('getTwoPlayerWinner requires at least 2 active players')
}
const score1 = scores[player1] || 0
const score2 = scores[player2] || 0
if (score1 > score2) {
return {
winner: 1,
winnerScore: scores[1],
loserScore: scores[2],
margin: scores[1] - scores[2]
winner: player1,
winnerScore: score1,
loserScore: score2,
margin: score1 - score2
}
} else if (scores[2] > scores[1]) {
} else if (score2 > score1) {
return {
winner: 2,
winnerScore: scores[2],
loserScore: scores[1],
margin: scores[2] - scores[1]
winner: player2,
winnerScore: score2,
loserScore: score1,
margin: score2 - score1
}
} else {
return {
winner: 'tie',
winnerScore: scores[1],
loserScore: scores[2],
winnerScore: score1,
loserScore: score2,
margin: 0
}
}
@@ -287,7 +296,7 @@ export function getTwoPlayerWinner(state: MemoryPairsState): {
export function getMultiplayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
winners: Player[]
winnerScore: number
scores: { [playerId: number]: number }
scores: { [playerId: string]: number }
isTie: boolean
} {
const { scores } = state

View File

@@ -53,11 +53,12 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
// Dynamic styling based on card type and state
const getCardBackGradient = () => {
if (isMatched) {
// Player-specific colors for matched cards
if (card.matchedBy === 1) {
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for player 1
} else if (card.matchedBy === 2) {
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for player 2
// Player-specific colors for matched cards - use array index lookup
const playerIndex = card.matchedBy ? activePlayers.findIndex(p => p.id === card.matchedBy) : -1
if (playerIndex === 0) {
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for first player
} else if (playerIndex === 1) {
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for second player
}
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player
}
@@ -77,8 +78,9 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
const getCardBackIcon = () => {
if (isMatched) {
// Show player emoji for matched cards in multiplayer mode
if (card.matchedBy && card.matchedBy <= activePlayers.length) {
return activePlayers[card.matchedBy - 1]?.emoji || '✓'
if (card.matchedBy) {
const player = activePlayers.find(p => p.id === card.matchedBy)
return player?.emoji || '✓'
}
return '✓' // Default checkmark for single player
}
@@ -97,11 +99,12 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
const getBorderColor = () => {
if (isMatched) {
// Player-specific border colors for matched cards
if (card.matchedBy === 1) {
return '#74b9ff' // Blue for player 1
} else if (card.matchedBy === 2) {
return '#fd79a8' // Pink for player 2
// Player-specific border colors for matched cards - use array index lookup
const playerIndex = card.matchedBy ? activePlayers.findIndex(p => p.id === card.matchedBy) : -1
if (playerIndex === 0) {
return '#74b9ff' // Blue for first player
} else if (playerIndex === 1) {
return '#fd79a8' // Pink for second player
}
return '#48bb78' // Default green for single player
}
@@ -164,11 +167,15 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
style={{
borderColor: getBorderColor(),
boxShadow: isMatched
? card.matchedBy === 1
? '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for player 1
: card.matchedBy === 2
? '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for player 2
: '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow
? (() => {
const playerIndex = card.matchedBy ? activePlayers.findIndex(p => p.id === card.matchedBy) : -1
if (playerIndex === 0) {
return '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for first player
} else if (playerIndex === 1) {
return '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for second player
}
return '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow
})()
: isFlipped
? '0 0 15px rgba(102, 126, 234, 0.3)'
: 'none'
@@ -186,7 +193,10 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
height: '32px',
borderRadius: '50%',
border: '3px solid',
borderColor: card.matchedBy === 1 ? '#74b9ff' : '#fd79a8',
borderColor: (() => {
const playerIndex = activePlayers.findIndex(p => p.id === card.matchedBy)
return playerIndex === 0 ? '#74b9ff' : '#fd79a8'
})(),
animation: 'explosionRing 0.6s ease-out',
zIndex: 9
})} />
@@ -199,16 +209,22 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
width: '32px',
height: '32px',
borderRadius: '50%',
background: card.matchedBy === 1
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
: 'linear-gradient(135deg, #fd79a8, #e84393)',
background: (() => {
const playerIndex = activePlayers.findIndex(p => p.id === card.matchedBy)
return playerIndex === 0
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
: 'linear-gradient(135deg, #fd79a8, #e84393)'
})(),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
boxShadow: card.matchedBy === 1
? '0 0 20px rgba(116, 185, 255, 0.6), 0 0 40px rgba(116, 185, 255, 0.4)'
: '0 0 20px rgba(253, 121, 168, 0.6), 0 0 40px rgba(253, 121, 168, 0.4)',
boxShadow: (() => {
const playerIndex = activePlayers.findIndex(p => p.id === card.matchedBy)
return playerIndex === 0
? '0 0 20px rgba(116, 185, 255, 0.6), 0 0 40px rgba(116, 185, 255, 0.4)'
: '0 0 20px rgba(253, 121, 168, 0.6), 0 0 40px rgba(253, 121, 168, 0.4)'
})(),
animation: 'epicClaim 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
zIndex: 10,
'&::before': {
@@ -219,9 +235,12 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
right: '-2px',
bottom: '-2px',
borderRadius: '50%',
background: card.matchedBy === 1
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)',
background: (() => {
const playerIndex = activePlayers.findIndex(p => p.id === card.matchedBy)
return playerIndex === 0
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)'
})(),
animation: 'spinningHalo 2s linear infinite',
zIndex: -1
}
@@ -230,9 +249,7 @@ export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false
animation: 'emojiBlast 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.4s both',
filter: 'drop-shadow(0 0 8px rgba(255,255,255,0.8))'
})}>
{card.matchedBy && card.matchedBy <= activePlayers.length
? activePlayers[card.matchedBy - 1]?.emoji || '✓'
: '✓'}
{activePlayers.find(p => p.id === card.matchedBy)?.emoji || '✓'}
</span>
</div>

View File

@@ -17,8 +17,9 @@ export function GamePhase() {
.map(id => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Map numeric player ID (1, 2, 3...) to actual player data
const currentPlayerData = activePlayersArray[state.currentPlayer - 1]
// Map player ID (UUID string) to actual player data using array index
const currentPlayerIndex = activePlayers.findIndex(id => id === state.currentPlayer)
const currentPlayerData = currentPlayerIndex >= 0 ? activePlayersArray[currentPlayerIndex] : undefined
const activePlayerData = activePlayersArray
return (

View File

@@ -102,7 +102,7 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
alignItems: 'center'
})}>
{activePlayers.map((player, index) => {
const isCurrentPlayer = (index + 1) === state.currentPlayer
const isCurrentPlayer = player.id === state.currentPlayer
const isLeading = player.score === Math.max(...activePlayers.map(p => p.score)) && player.score > 0
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)

View File

@@ -16,11 +16,10 @@ export function ResultsPhase() {
const activePlayerData = Array.from(activePlayerIds)
.map(id => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
.map((player, index) => ({
.map((player) => ({
...player,
displayName: player.name,
displayEmoji: player.emoji,
numericId: index + 1 // For compatibility with state.scores
displayEmoji: player.emoji
}))
const gameTime = state.gameEndTime && state.gameStartTime
@@ -73,7 +72,7 @@ export function ResultsPhase() {
color: 'blue.600',
fontWeight: 'bold'
})}>
🏆 {activePlayerData.find(p => p.numericId === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
🏆 {activePlayerData.find(p => p.id === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
</p>
) : (
<p className={css({
@@ -186,8 +185,8 @@ export function ResultsPhase() {
flexWrap: 'wrap'
})}>
{activePlayerData.map((player) => {
const score = multiplayerResult.scores[player.numericId] || 0
const isWinner = multiplayerResult.winners.includes(player.numericId)
const score = multiplayerResult.scores[player.id] || 0
const isWinner = multiplayerResult.winners.includes(player.id)
return (
<div key={player.id} className={css({

View File

@@ -28,7 +28,7 @@ const initialState: MemoryPairsState = {
// Game progression
gamePhase: 'setup',
currentPlayer: 1,
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
@@ -76,7 +76,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
case 'START_GAME':
// Initialize scores and consecutive matches for all active players
const scores: PlayerScore = {}
const consecutiveMatches: { [playerId: number]: number } = {}
const consecutiveMatches: { [playerId: string]: number } = {}
action.activePlayers.forEach(playerId => {
scores[playerId] = 0
consecutiveMatches[playerId] = 0
@@ -93,7 +93,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
scores,
consecutiveMatches,
activePlayers: action.activePlayers,
currentPlayer: action.activePlayers[0] || 1,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
@@ -253,8 +253,8 @@ export function MemoryPairsProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(memoryPairsReducer, initialState)
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
// Get active player IDs as numbers (convert from string IDs for now to maintain compatibility)
const activePlayers = Array.from(activePlayerIds).map((id, index) => index + 1)
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
@@ -358,6 +358,7 @@ export function MemoryPairsProvider({ children }: { children: ReactNode }) {
resetGame,
setGameType,
setDifficulty,
exitSession: () => {}, // No-op for non-arcade mode
gameMode, // Expose derived gameMode
activePlayers // Expose active players
}

View File

@@ -5,7 +5,7 @@ export type GameType = 'abacus-numeral' | 'complement-pairs'
export type GamePhase = 'setup' | 'playing' | 'results'
export type CardType = 'abacus' | 'number' | 'complement'
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
export type Player = number // Now supports any player ID
export type Player = string // Player ID (UUID)
export type TargetSum = 5 | 10 | 20
export interface GameCard {
@@ -20,7 +20,7 @@ export interface GameCard {
}
export interface PlayerScore {
[playerId: number]: number
[playerId: string]: number
}
export interface CelebrationAnimation {
@@ -59,7 +59,7 @@ export interface MemoryPairsState {
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
consecutiveMatches: { [playerId: number]: number } // Track consecutive matches per player
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// Timing
gameStartTime: number | null
@@ -108,6 +108,7 @@ export interface MemoryPairsContextValue {
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
}
// Utility types for component props

View File

@@ -251,33 +251,42 @@ export function formatGameTime(milliseconds: number): string {
}
// Get two-player game winner
export function getTwoPlayerWinner(state: MemoryPairsState): {
// @deprecated Use getMultiplayerWinner instead which supports N players
export function getTwoPlayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
winner: Player | 'tie'
winnerScore: number
loserScore: number
margin: number
} {
const { scores } = state
const [player1, player2] = activePlayers
if (scores[1] > scores[2]) {
if (!player1 || !player2) {
throw new Error('getTwoPlayerWinner requires at least 2 active players')
}
const score1 = scores[player1] || 0
const score2 = scores[player2] || 0
if (score1 > score2) {
return {
winner: 1,
winnerScore: scores[1],
loserScore: scores[2],
margin: scores[1] - scores[2]
winner: player1,
winnerScore: score1,
loserScore: score2,
margin: score1 - score2
}
} else if (scores[2] > scores[1]) {
} else if (score2 > score1) {
return {
winner: 2,
winnerScore: scores[2],
loserScore: scores[1],
margin: scores[2] - scores[1]
winner: player2,
winnerScore: score2,
loserScore: score1,
margin: score2 - score1
}
} else {
return {
winner: 'tie',
winnerScore: scores[1],
loserScore: scores[2],
winnerScore: score1,
loserScore: score2,
margin: 0
}
}
@@ -287,7 +296,7 @@ export function getTwoPlayerWinner(state: MemoryPairsState): {
export function getMultiplayerWinner(state: MemoryPairsState, activePlayers: Player[]): {
winners: Player[]
winnerScore: number
scores: { [playerId: number]: number }
scores: { [playerId: string]: number }
isTie: boolean
} {
const { scores } = state

View File

@@ -8,7 +8,7 @@ import type {
ValidationResult,
MatchingGameMove,
} from './types'
import type { MemoryPairsState, GameCard, Difficulty, GameType } from '@/app/games/matching/context/types'
import type { MemoryPairsState, GameCard, Difficulty, GameType, Player } from '@/app/games/matching/context/types'
import { validateMatch, canFlipCard } from '@/app/games/matching/utils/matchValidation'
import { generateGameCards } from '@/app/games/matching/utils/cardGeneration'
@@ -45,11 +45,13 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
}
}
// Skip turn validation in arcade mode where a single user controls multiple players
// In this case, playerId is the viewer ID (UUID), not a player number
// Turn validation only applies to true multiplayer with different users
// Since we can't distinguish here, we disable turn validation for arcade sessions
// (A better solution would be to pass both viewerId and playerNumber in moves)
// Check if it's the player's turn (in multiplayer)
if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) {
return {
valid: false,
error: 'Not your turn',
}
}
// Find the card
const card = state.gameCards.find(c => c.id === cardId)
@@ -147,7 +149,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
private validateStartGame(
state: MemoryPairsState,
activePlayers: number[],
activePlayers: Player[],
cards?: GameCard[]
): ValidationResult {
// Allow starting a new game from any phase (for "New Game" button)
@@ -214,7 +216,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
difficulty: config.difficulty,
turnTimer: config.turnTimer,
gamePhase: 'setup',
currentPlayer: 1,
currentPlayer: '',
matchedPairs: 0,
totalPairs: config.difficulty,
moves: 0,

View File

@@ -31,7 +31,7 @@ export interface MatchingFlipCardMove extends GameMove {
export interface MatchingStartGameMove extends GameMove {
type: 'START_GAME'
data: {
activePlayers: number[]
activePlayers: string[] // Player IDs (UUIDs)
cards?: any[] // GameCard type from context
}
}