feat(card-sorting): add UI components and fix AbacusReact props

- Create SetupPhase component with config selection
- Create PlayingPhase component with card sorting interface
- Create ResultsPhase component with detailed score breakdown
- Update GameComponent to route between phases
- Fix AbacusReact props (columns, scaleFactor instead of width/height)

All components feature:
- Setup: Card count selector, reveal numbers toggle, resume game support
- Playing: Interactive card grid, gradient position slots, timer, progress tracking
- Results: Score visualization, metric breakdown (LCS 50%, exact 30%, inversions 20%), visual comparison

🤖 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-18 14:22:48 -05:00
parent 0dab5da0c7
commit d249ec0e5f
11 changed files with 2182 additions and 1176 deletions

View File

@@ -1,13 +1,13 @@
'use client'
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
createContext,
useContext,
useState,
type ReactNode,
useCallback,
useEffect,
useMemo,
createContext,
useContext,
useState,
} from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
@@ -16,38 +16,30 @@ import { buildPlayerMetadata as buildPlayerMetadataUtil } from '@/lib/arcade/pla
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '@/contexts/GameModeContext'
import { generateRandomCards, shuffleCards } from './utils/cardGeneration'
import type {
CardSortingState,
CardSortingMove,
SortingCard,
CardSortingConfig,
} from './types'
import type { CardSortingState, CardSortingMove, SortingCard, CardSortingConfig } from './types'
// Context value interface
interface CardSortingContextValue {
state: CardSortingState
// Actions
startGame: () => void
placeCard: (cardId: string, position: number) => void
removeCard: (position: number) => void
checkSolution: () => void
revealNumbers: () => void
goToSetup: () => void
resumeGame: () => void
setConfig: (
field: 'cardCount' | 'showNumbers' | 'timeLimit',
value: unknown,
) => void
exitSession: () => void
// Computed
canCheckSolution: boolean
placedCount: number
elapsedTime: number
hasConfigChanged: boolean
canResumeGame: boolean
// UI state
selectedCardId: string | null
selectCard: (cardId: string | null) => void
state: CardSortingState
// Actions
startGame: () => void
placeCard: (cardId: string, position: number) => void
removeCard: (position: number) => void
checkSolution: () => void
revealNumbers: () => void
goToSetup: () => void
resumeGame: () => void
setConfig: (field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => void
exitSession: () => void
// Computed
canCheckSolution: boolean
placedCount: number
elapsedTime: number
hasConfigChanged: boolean
canResumeGame: boolean
// UI state
selectedCardId: string | null
selectCard: (cardId: string | null) => void
}
// Create context
@@ -55,471 +47,444 @@ const CardSortingContext = createContext<CardSortingContextValue | null>(null)
// Initial state matching validator's getInitialState
const createInitialState = (config: Partial<CardSortingConfig>): CardSortingState => ({
cardCount: config.cardCount ?? 8,
showNumbers: config.showNumbers ?? true,
timeLimit: config.timeLimit ?? null,
gamePhase: 'setup',
playerId: '',
playerMetadata: {
id: '',
name: '',
emoji: '',
userId: '',
},
gameStartTime: null,
gameEndTime: null,
selectedCards: [],
correctOrder: [],
availableCards: [],
placedCards: new Array(config.cardCount ?? 8).fill(null),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
cardCount: config.cardCount ?? 8,
showNumbers: config.showNumbers ?? true,
timeLimit: config.timeLimit ?? null,
gamePhase: 'setup',
playerId: '',
playerMetadata: {
id: '',
name: '',
emoji: '',
userId: '',
},
gameStartTime: null,
gameEndTime: null,
selectedCards: [],
correctOrder: [],
availableCards: [],
placedCards: new Array(config.cardCount ?? 8).fill(null),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
})
/**
* Optimistic move application (client-side prediction)
*/
function applyMoveOptimistically(
state: CardSortingState,
move: GameMove,
): CardSortingState {
const typedMove = move as CardSortingMove
function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardSortingState {
const typedMove = move as CardSortingMove
switch (typedMove.type) {
case 'START_GAME': {
const selectedCards = typedMove.data.selectedCards as SortingCard[]
const correctOrder = [...selectedCards].sort((a, b) => a.number - b.number)
switch (typedMove.type) {
case 'START_GAME': {
const selectedCards = typedMove.data.selectedCards as SortingCard[]
const correctOrder = [...selectedCards].sort((a, b) => a.number - b.number)
return {
...state,
gamePhase: 'playing',
playerId: typedMove.playerId,
playerMetadata: typedMove.data.playerMetadata,
gameStartTime: Date.now(),
selectedCards,
correctOrder,
availableCards: shuffleCards(selectedCards),
placedCards: new Array(state.cardCount).fill(null),
numbersRevealed: false,
// Save original config for pause/resume
originalConfig: {
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
return {
...state,
gamePhase: 'playing',
playerId: typedMove.playerId,
playerMetadata: typedMove.data.playerMetadata,
gameStartTime: Date.now(),
selectedCards,
correctOrder,
availableCards: shuffleCards(selectedCards),
placedCards: new Array(state.cardCount).fill(null),
numbersRevealed: false,
// Save original config for pause/resume
originalConfig: {
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
case 'PLACE_CARD': {
const { cardId, position } = typedMove.data
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) return state
case 'PLACE_CARD': {
const { cardId, position } = typedMove.data
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) return state
// Simple insert logic (server will do proper compaction)
const newPlaced = [...state.placedCards]
newPlaced[position] = card
const newAvailable = state.availableCards.filter((c) => c.id !== cardId)
// Simple insert logic (server will do proper compaction)
const newPlaced = [...state.placedCards]
newPlaced[position] = card
const newAvailable = state.availableCards.filter((c) => c.id !== cardId)
return {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
}
}
return {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
}
}
case 'REMOVE_CARD': {
const { position } = typedMove.data
const card = state.placedCards[position]
if (!card) return state
case 'REMOVE_CARD': {
const { position } = typedMove.data
const card = state.placedCards[position]
if (!card) return state
const newPlaced = [...state.placedCards]
newPlaced[position] = null
const newAvailable = [...state.availableCards, card]
const newPlaced = [...state.placedCards]
newPlaced[position] = null
const newAvailable = [...state.availableCards, card]
return {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
}
}
return {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
}
}
case 'REVEAL_NUMBERS': {
return {
...state,
numbersRevealed: true,
}
}
case 'REVEAL_NUMBERS': {
return {
...state,
numbersRevealed: true,
}
}
case 'CHECK_SOLUTION': {
// Server will calculate score - just transition to results optimistically
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
case 'CHECK_SOLUTION': {
// Server will calculate score - just transition to results optimistically
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
case 'GO_TO_SETUP': {
const isPausingGame = state.gamePhase === 'playing'
case 'GO_TO_SETUP': {
const isPausingGame = state.gamePhase === 'playing'
return {
...createInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
// Save paused state if coming from active game
originalConfig: state.originalConfig,
pausedGamePhase: isPausingGame ? 'playing' : undefined,
pausedGameState: isPausingGame
? {
selectedCards: state.selectedCards,
availableCards: state.availableCards,
placedCards: state.placedCards,
gameStartTime: state.gameStartTime || Date.now(),
numbersRevealed: state.numbersRevealed,
}
: undefined,
}
}
return {
...createInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
// Save paused state if coming from active game
originalConfig: state.originalConfig,
pausedGamePhase: isPausingGame ? 'playing' : undefined,
pausedGameState: isPausingGame
? {
selectedCards: state.selectedCards,
availableCards: state.availableCards,
placedCards: state.placedCards,
gameStartTime: state.gameStartTime || Date.now(),
numbersRevealed: state.numbersRevealed,
}
: undefined,
}
}
case 'SET_CONFIG': {
const { field, value } = typedMove.data
const clearPausedGame = !!state.pausedGamePhase
case 'SET_CONFIG': {
const { field, value } = typedMove.data
const clearPausedGame = !!state.pausedGamePhase
return {
...state,
[field]: value,
// Update placedCards array size if cardCount changes
...(field === 'cardCount'
? { placedCards: new Array(value as number).fill(null) }
: {}),
// Clear paused game if config changed
...(clearPausedGame
? {
pausedGamePhase: undefined,
pausedGameState: undefined,
originalConfig: undefined,
}
: {}),
}
}
return {
...state,
[field]: value,
// Update placedCards array size if cardCount changes
...(field === 'cardCount' ? { placedCards: new Array(value as number).fill(null) } : {}),
// Clear paused game if config changed
...(clearPausedGame
? {
pausedGamePhase: undefined,
pausedGameState: undefined,
originalConfig: undefined,
}
: {}),
}
}
case 'RESUME_GAME': {
if (!state.pausedGamePhase || !state.pausedGameState) {
return state
}
case 'RESUME_GAME': {
if (!state.pausedGamePhase || !state.pausedGameState) {
return state
}
const correctOrder = [...state.pausedGameState.selectedCards].sort(
(a, b) => a.number - b.number,
)
const correctOrder = [...state.pausedGameState.selectedCards].sort(
(a, b) => a.number - b.number
)
return {
...state,
gamePhase: state.pausedGamePhase,
selectedCards: state.pausedGameState.selectedCards,
correctOrder,
availableCards: state.pausedGameState.availableCards,
placedCards: state.pausedGameState.placedCards,
gameStartTime: state.pausedGameState.gameStartTime,
numbersRevealed: state.pausedGameState.numbersRevealed,
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
return {
...state,
gamePhase: state.pausedGamePhase,
selectedCards: state.pausedGameState.selectedCards,
correctOrder,
availableCards: state.pausedGameState.availableCards,
placedCards: state.pausedGameState.placedCards,
gameStartTime: state.pausedGameState.gameStartTime,
numbersRevealed: state.pausedGameState.numbersRevealed,
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
default:
return state
}
default:
return state
}
}
/**
* Card Sorting Provider - Single Player Pattern Recognition Game
*/
export function CardSortingProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Local UI state (not synced to server)
const [selectedCardId, setSelectedCardId] = useState<string | null>(null)
// Local UI state (not synced to server)
const [selectedCardId, setSelectedCardId] = useState<string | null>(null)
// Get local player (single player game)
const localPlayerId = useMemo(() => {
return Array.from(activePlayers).find((id) => {
const player = players.get(id)
return player?.isLocal !== false
})
}, [activePlayers, players])
// Get local player (single player game)
const localPlayerId = useMemo(() => {
return Array.from(activePlayers).find((id) => {
const player = players.get(id)
return player?.isLocal !== false
})
}, [activePlayers, players])
// Merge saved config from room data
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null
const savedConfig = gameConfig?.['card-sorting'] as
| Partial<CardSortingConfig>
| undefined
// Merge saved config from room data
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null
const savedConfig = gameConfig?.['card-sorting'] as Partial<CardSortingConfig> | undefined
return createInitialState(savedConfig || {})
}, [roomData?.gameConfig])
return createInitialState(savedConfig || {})
}, [roomData?.gameConfig])
// Arcade session integration
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Arcade session integration
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Build player metadata for the single local player
const buildPlayerMetadata = useCallback(() => {
if (!localPlayerId) {
return {
id: '',
name: '',
emoji: '',
userId: '',
}
}
// Build player metadata for the single local player
const buildPlayerMetadata = useCallback(() => {
if (!localPlayerId) {
return {
id: '',
name: '',
emoji: '',
userId: '',
}
}
const playerOwnership: Record<string, string> = {}
if (viewerId) {
playerOwnership[localPlayerId] = viewerId
}
const playerOwnership: Record<string, string> = {}
if (viewerId) {
playerOwnership[localPlayerId] = viewerId
}
const metadata = buildPlayerMetadataUtil(
[localPlayerId],
playerOwnership,
players,
viewerId ?? undefined,
)
const metadata = buildPlayerMetadataUtil(
[localPlayerId],
playerOwnership,
players,
viewerId ?? undefined
)
return metadata[localPlayerId] || { id: '', name: '', emoji: '', userId: '' }
}, [localPlayerId, players, viewerId])
return metadata[localPlayerId] || { id: '', name: '', emoji: '', userId: '' }
}, [localPlayerId, players, viewerId])
// Computed values
const canCheckSolution = useMemo(
() => state.placedCards.every((c) => c !== null),
[state.placedCards],
)
// Computed values
const canCheckSolution = useMemo(
() => state.placedCards.every((c) => c !== null),
[state.placedCards]
)
const placedCount = useMemo(
() => state.placedCards.filter((c) => c !== null).length,
[state.placedCards],
)
const placedCount = useMemo(
() => state.placedCards.filter((c) => c !== null).length,
[state.placedCards]
)
const elapsedTime = useMemo(() => {
if (!state.gameStartTime) return 0
const now = state.gameEndTime || Date.now()
return Math.floor((now - state.gameStartTime) / 1000)
}, [state.gameStartTime, state.gameEndTime])
const elapsedTime = useMemo(() => {
if (!state.gameStartTime) return 0
const now = state.gameEndTime || Date.now()
return Math.floor((now - state.gameStartTime) / 1000)
}, [state.gameStartTime, state.gameEndTime])
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
state.cardCount !== state.originalConfig.cardCount ||
state.showNumbers !== state.originalConfig.showNumbers ||
state.timeLimit !== state.originalConfig.timeLimit
)
}, [state.cardCount, state.showNumbers, state.timeLimit, state.originalConfig])
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
state.cardCount !== state.originalConfig.cardCount ||
state.showNumbers !== state.originalConfig.showNumbers ||
state.timeLimit !== state.originalConfig.timeLimit
)
}, [state.cardCount, state.showNumbers, state.timeLimit, state.originalConfig])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Action creators
const startGame = useCallback(() => {
if (!localPlayerId) {
console.error('[CardSortingProvider] No local player available')
return
}
// Action creators
const startGame = useCallback(() => {
if (!localPlayerId) {
console.error('[CardSortingProvider] No local player available')
return
}
const playerMetadata = buildPlayerMetadata()
const selectedCards = generateRandomCards(state.cardCount)
const playerMetadata = buildPlayerMetadata()
const selectedCards = generateRandomCards(state.cardCount)
sendMove({
type: 'START_GAME',
playerId: localPlayerId,
userId: viewerId || '',
data: {
playerMetadata,
selectedCards,
},
})
}, [
localPlayerId,
state.cardCount,
buildPlayerMetadata,
sendMove,
viewerId,
])
sendMove({
type: 'START_GAME',
playerId: localPlayerId,
userId: viewerId || '',
data: {
playerMetadata,
selectedCards,
},
})
}, [localPlayerId, state.cardCount, buildPlayerMetadata, sendMove, viewerId])
const placeCard = useCallback(
(cardId: string, position: number) => {
if (!localPlayerId) return
const placeCard = useCallback(
(cardId: string, position: number) => {
if (!localPlayerId) return
sendMove({
type: 'PLACE_CARD',
playerId: localPlayerId,
userId: viewerId || '',
data: { cardId, position },
})
sendMove({
type: 'PLACE_CARD',
playerId: localPlayerId,
userId: viewerId || '',
data: { cardId, position },
})
// Clear selection
setSelectedCardId(null)
},
[localPlayerId, sendMove, viewerId],
)
// Clear selection
setSelectedCardId(null)
},
[localPlayerId, sendMove, viewerId]
)
const removeCard = useCallback(
(position: number) => {
if (!localPlayerId) return
const removeCard = useCallback(
(position: number) => {
if (!localPlayerId) return
sendMove({
type: 'REMOVE_CARD',
playerId: localPlayerId,
userId: viewerId || '',
data: { position },
})
},
[localPlayerId, sendMove, viewerId],
)
sendMove({
type: 'REMOVE_CARD',
playerId: localPlayerId,
userId: viewerId || '',
data: { position },
})
},
[localPlayerId, sendMove, viewerId]
)
const checkSolution = useCallback(() => {
if (!localPlayerId) return
if (!canCheckSolution) {
console.warn('[CardSortingProvider] Cannot check - not all cards placed')
return
}
const checkSolution = useCallback(() => {
if (!localPlayerId) return
if (!canCheckSolution) {
console.warn('[CardSortingProvider] Cannot check - not all cards placed')
return
}
sendMove({
type: 'CHECK_SOLUTION',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canCheckSolution, sendMove, viewerId])
sendMove({
type: 'CHECK_SOLUTION',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canCheckSolution, sendMove, viewerId])
const revealNumbers = useCallback(() => {
if (!localPlayerId) return
const revealNumbers = useCallback(() => {
if (!localPlayerId) return
sendMove({
type: 'REVEAL_NUMBERS',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, sendMove, viewerId])
sendMove({
type: 'REVEAL_NUMBERS',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, sendMove, viewerId])
const goToSetup = useCallback(() => {
if (!localPlayerId) return
const goToSetup = useCallback(() => {
if (!localPlayerId) return
sendMove({
type: 'GO_TO_SETUP',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, sendMove, viewerId])
sendMove({
type: 'GO_TO_SETUP',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, sendMove, viewerId])
const resumeGame = useCallback(() => {
if (!localPlayerId || !canResumeGame) {
console.warn(
'[CardSortingProvider] Cannot resume - no paused game or config changed',
)
return
}
const resumeGame = useCallback(() => {
if (!localPlayerId || !canResumeGame) {
console.warn('[CardSortingProvider] Cannot resume - no paused game or config changed')
return
}
sendMove({
type: 'RESUME_GAME',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canResumeGame, sendMove, viewerId])
sendMove({
type: 'RESUME_GAME',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canResumeGame, sendMove, viewerId])
const setConfig = useCallback(
(
field: 'cardCount' | 'showNumbers' | 'timeLimit',
value: unknown,
) => {
if (!localPlayerId) return
const setConfig = useCallback(
(field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => {
if (!localPlayerId) return
sendMove({
type: 'SET_CONFIG',
playerId: localPlayerId,
userId: viewerId || '',
data: { field, value },
})
sendMove({
type: 'SET_CONFIG',
playerId: localPlayerId,
userId: viewerId || '',
data: { field, value },
})
// Persist to database
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<
string,
unknown
>) || {}
const currentCardSortingConfig =
(currentGameConfig['card-sorting'] as Record<string, unknown>) || {}
// Persist to database
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
const currentCardSortingConfig =
(currentGameConfig['card-sorting'] as Record<string, unknown>) || {}
const updatedConfig = {
...currentGameConfig,
'card-sorting': {
...currentCardSortingConfig,
[field]: value,
},
}
const updatedConfig = {
...currentGameConfig,
'card-sorting': {
...currentCardSortingConfig,
[field]: value,
},
}
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
}
},
[localPlayerId, sendMove, viewerId, roomData, updateGameConfig],
)
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
}
},
[localPlayerId, sendMove, viewerId, roomData, updateGameConfig]
)
const contextValue: CardSortingContextValue = {
state,
// Actions
startGame,
placeCard,
removeCard,
checkSolution,
revealNumbers,
goToSetup,
resumeGame,
setConfig,
exitSession,
// Computed
canCheckSolution,
placedCount,
elapsedTime,
hasConfigChanged,
canResumeGame,
// UI state
selectedCardId,
selectCard: setSelectedCardId,
}
const contextValue: CardSortingContextValue = {
state,
// Actions
startGame,
placeCard,
removeCard,
checkSolution,
revealNumbers,
goToSetup,
resumeGame,
setConfig,
exitSession,
// Computed
canCheckSolution,
placedCount,
elapsedTime,
hasConfigChanged,
canResumeGame,
// UI state
selectedCardId,
selectCard: setSelectedCardId,
}
return (
<CardSortingContext.Provider value={contextValue}>
{children}
</CardSortingContext.Provider>
)
return <CardSortingContext.Provider value={contextValue}>{children}</CardSortingContext.Provider>
}
/**
* Hook to access Card Sorting context
*/
export function useCardSorting() {
const context = useContext(CardSortingContext)
if (!context) {
throw new Error(
'useCardSorting must be used within CardSortingProvider',
)
}
return context
const context = useContext(CardSortingContext)
if (!context) {
throw new Error('useCardSorting must be used within CardSortingProvider')
}
return context
}

View File

@@ -1,425 +1,410 @@
import type { GameValidator, ValidationContext, ValidationResult } from '@/lib/arcade/validation/types'
import type {
GameValidator,
ValidationContext,
ValidationResult,
} from '@/lib/arcade/validation/types'
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
import { calculateScore } from './utils/scoringAlgorithm'
import { placeCardAtPosition, removeCardAtPosition } from './utils/validation'
export class CardSortingValidator
implements GameValidator<CardSortingState, CardSortingMove>
{
validateMove(
state: CardSortingState,
move: CardSortingMove,
context: ValidationContext,
): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data, move.playerId)
case 'PLACE_CARD':
return this.validatePlaceCard(
state,
move.data.cardId,
move.data.position,
)
case 'REMOVE_CARD':
return this.validateRemoveCard(state, move.data.position)
case 'REVEAL_NUMBERS':
return this.validateRevealNumbers(state)
case 'CHECK_SOLUTION':
return this.validateCheckSolution(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'RESUME_GAME':
return this.validateResumeGame(state)
default:
return {
valid: false,
error: `Unknown move type: ${(move as CardSortingMove).type}`,
}
}
}
export class CardSortingValidator implements GameValidator<CardSortingState, CardSortingMove> {
validateMove(
state: CardSortingState,
move: CardSortingMove,
context: ValidationContext
): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data, move.playerId)
case 'PLACE_CARD':
return this.validatePlaceCard(state, move.data.cardId, move.data.position)
case 'REMOVE_CARD':
return this.validateRemoveCard(state, move.data.position)
case 'REVEAL_NUMBERS':
return this.validateRevealNumbers(state)
case 'CHECK_SOLUTION':
return this.validateCheckSolution(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'RESUME_GAME':
return this.validateResumeGame(state)
default:
return {
valid: false,
error: `Unknown move type: ${(move as CardSortingMove).type}`,
}
}
}
private validateStartGame(
state: CardSortingState,
data: { playerMetadata: unknown; selectedCards: unknown },
playerId: string,
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only start game from setup phase',
}
}
private validateStartGame(
state: CardSortingState,
data: { playerMetadata: unknown; selectedCards: unknown },
playerId: string
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only start game from setup phase',
}
}
// Validate selectedCards
if (!Array.isArray(data.selectedCards)) {
return { valid: false, error: 'selectedCards must be an array' }
}
// Validate selectedCards
if (!Array.isArray(data.selectedCards)) {
return { valid: false, error: 'selectedCards must be an array' }
}
if (data.selectedCards.length !== state.cardCount) {
return {
valid: false,
error: `Must provide exactly ${state.cardCount} cards`,
}
}
if (data.selectedCards.length !== state.cardCount) {
return {
valid: false,
error: `Must provide exactly ${state.cardCount} cards`,
}
}
const selectedCards = data.selectedCards as unknown[]
const selectedCards = data.selectedCards as unknown[]
// Create correct order (sorted)
const correctOrder = [...selectedCards].sort((a: unknown, b: unknown) => {
const cardA = a as { number: number }
const cardB = b as { number: number }
return cardA.number - cardB.number
})
// Create correct order (sorted)
const correctOrder = [...selectedCards].sort((a: unknown, b: unknown) => {
const cardA = a as { number: number }
const cardB = b as { number: number }
return cardA.number - cardB.number
})
return {
valid: true,
newState: {
...state,
gamePhase: 'playing',
playerId,
playerMetadata: data.playerMetadata,
gameStartTime: Date.now(),
selectedCards: selectedCards as typeof state.selectedCards,
correctOrder: correctOrder as typeof state.correctOrder,
availableCards: selectedCards as typeof state.availableCards,
placedCards: new Array(state.cardCount).fill(null),
numbersRevealed: false,
},
}
}
return {
valid: true,
newState: {
...state,
gamePhase: 'playing',
playerId,
playerMetadata: data.playerMetadata,
gameStartTime: Date.now(),
selectedCards: selectedCards as typeof state.selectedCards,
correctOrder: correctOrder as typeof state.correctOrder,
availableCards: selectedCards as typeof state.availableCards,
placedCards: new Array(state.cardCount).fill(null),
numbersRevealed: false,
},
}
}
private validatePlaceCard(
state: CardSortingState,
cardId: string,
position: number,
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only place cards during playing phase' }
}
private validatePlaceCard(
state: CardSortingState,
cardId: string,
position: number
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only place cards during playing phase' }
}
// Card must exist in availableCards
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) {
return { valid: false, error: 'Card not found in available cards' }
}
// Card must exist in availableCards
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) {
return { valid: false, error: 'Card not found in available cards' }
}
// Position must be valid (0 to cardCount-1)
if (position < 0 || position >= state.cardCount) {
return {
valid: false,
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
}
}
// Position must be valid (0 to cardCount-1)
if (position < 0 || position >= state.cardCount) {
return {
valid: false,
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
}
}
// Place the card using utility function
const { placedCards: newPlaced } = placeCardAtPosition(
state.placedCards,
card,
position,
state.cardCount,
)
// Place the card using utility function
const { placedCards: newPlaced } = placeCardAtPosition(
state.placedCards,
card,
position,
state.cardCount
)
// Remove from available
const newAvailable = state.availableCards.filter((c) => c.id !== cardId)
// Remove from available
const newAvailable = state.availableCards.filter((c) => c.id !== cardId)
return {
valid: true,
newState: {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
},
}
}
return {
valid: true,
newState: {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
},
}
}
private validateRemoveCard(
state: CardSortingState,
position: number,
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only remove cards during playing phase',
}
}
private validateRemoveCard(state: CardSortingState, position: number): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only remove cards during playing phase',
}
}
// Position must be valid
if (position < 0 || position >= state.cardCount) {
return {
valid: false,
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
}
}
// Position must be valid
if (position < 0 || position >= state.cardCount) {
return {
valid: false,
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
}
}
// Card must exist at position
if (state.placedCards[position] === null) {
return { valid: false, error: 'No card at this position' }
}
// Card must exist at position
if (state.placedCards[position] === null) {
return { valid: false, error: 'No card at this position' }
}
// Remove the card using utility function
const { placedCards: newPlaced, removedCard } = removeCardAtPosition(
state.placedCards,
position,
)
// Remove the card using utility function
const { placedCards: newPlaced, removedCard } = removeCardAtPosition(
state.placedCards,
position
)
if (!removedCard) {
return { valid: false, error: 'Failed to remove card' }
}
if (!removedCard) {
return { valid: false, error: 'Failed to remove card' }
}
// Add back to available
const newAvailable = [...state.availableCards, removedCard]
// Add back to available
const newAvailable = [...state.availableCards, removedCard]
return {
valid: true,
newState: {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
},
}
}
return {
valid: true,
newState: {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
},
}
}
private validateRevealNumbers(
state: CardSortingState,
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only reveal numbers during playing phase',
}
}
private validateRevealNumbers(state: CardSortingState): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only reveal numbers during playing phase',
}
}
// Must be enabled in config
if (!state.showNumbers) {
return { valid: false, error: 'Reveal numbers is not enabled' }
}
// Must be enabled in config
if (!state.showNumbers) {
return { valid: false, error: 'Reveal numbers is not enabled' }
}
// Already revealed
if (state.numbersRevealed) {
return { valid: false, error: 'Numbers already revealed' }
}
// Already revealed
if (state.numbersRevealed) {
return { valid: false, error: 'Numbers already revealed' }
}
return {
valid: true,
newState: {
...state,
numbersRevealed: true,
},
}
}
return {
valid: true,
newState: {
...state,
numbersRevealed: true,
},
}
}
private validateCheckSolution(
state: CardSortingState,
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only check solution during playing phase',
}
}
private validateCheckSolution(state: CardSortingState): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only check solution during playing phase',
}
}
// All slots must be filled
if (state.placedCards.some((c) => c === null)) {
return { valid: false, error: 'Must place all cards before checking' }
}
// All slots must be filled
if (state.placedCards.some((c) => c === null)) {
return { valid: false, error: 'Must place all cards before checking' }
}
// Calculate score using scoring algorithms
const userSequence = state.placedCards.map((c) => c!.number)
const correctSequence = state.correctOrder.map((c) => c.number)
// Calculate score using scoring algorithms
const userSequence = state.placedCards.map((c) => c!.number)
const correctSequence = state.correctOrder.map((c) => c.number)
const scoreBreakdown = calculateScore(
userSequence,
correctSequence,
state.gameStartTime || Date.now(),
state.numbersRevealed,
)
const scoreBreakdown = calculateScore(
userSequence,
correctSequence,
state.gameStartTime || Date.now(),
state.numbersRevealed
)
return {
valid: true,
newState: {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
scoreBreakdown,
},
}
}
return {
valid: true,
newState: {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
scoreBreakdown,
},
}
}
private validateGoToSetup(
state: CardSortingState,
): ValidationResult {
// Save current game state for resume (if in playing phase)
if (state.gamePhase === 'playing') {
return {
valid: true,
newState: {
...this.getInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
originalConfig: {
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
},
pausedGamePhase: 'playing',
pausedGameState: {
selectedCards: state.selectedCards,
availableCards: state.availableCards,
placedCards: state.placedCards,
gameStartTime: state.gameStartTime || Date.now(),
numbersRevealed: state.numbersRevealed,
},
},
}
}
private validateGoToSetup(state: CardSortingState): ValidationResult {
// Save current game state for resume (if in playing phase)
if (state.gamePhase === 'playing') {
return {
valid: true,
newState: {
...this.getInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
originalConfig: {
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
},
pausedGamePhase: 'playing',
pausedGameState: {
selectedCards: state.selectedCards,
availableCards: state.availableCards,
placedCards: state.placedCards,
gameStartTime: state.gameStartTime || Date.now(),
numbersRevealed: state.numbersRevealed,
},
},
}
}
// Just go to setup
return {
valid: true,
newState: this.getInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
}
}
// Just go to setup
return {
valid: true,
newState: this.getInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
}
}
private validateSetConfig(
state: CardSortingState,
field: string,
value: unknown,
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change config in setup phase' }
}
private validateSetConfig(
state: CardSortingState,
field: string,
value: unknown
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change config in setup phase' }
}
// Validate field and value
switch (field) {
case 'cardCount':
if (![5, 8, 12, 15].includes(value as number)) {
return { valid: false, error: 'cardCount must be 5, 8, 12, or 15' }
}
return {
valid: true,
newState: {
...state,
cardCount: value as 5 | 8 | 12 | 15,
placedCards: new Array(value as number).fill(null),
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
// Validate field and value
switch (field) {
case 'cardCount':
if (![5, 8, 12, 15].includes(value as number)) {
return { valid: false, error: 'cardCount must be 5, 8, 12, or 15' }
}
return {
valid: true,
newState: {
...state,
cardCount: value as 5 | 8 | 12 | 15,
placedCards: new Array(value as number).fill(null),
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
case 'showNumbers':
if (typeof value !== 'boolean') {
return { valid: false, error: 'showNumbers must be a boolean' }
}
return {
valid: true,
newState: {
...state,
showNumbers: value,
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
case 'showNumbers':
if (typeof value !== 'boolean') {
return { valid: false, error: 'showNumbers must be a boolean' }
}
return {
valid: true,
newState: {
...state,
showNumbers: value,
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
case 'timeLimit':
if (value !== null && (typeof value !== 'number' || value < 30)) {
return {
valid: false,
error: 'timeLimit must be null or a number >= 30',
}
}
return {
valid: true,
newState: {
...state,
timeLimit: value as number | null,
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
case 'timeLimit':
if (value !== null && (typeof value !== 'number' || value < 30)) {
return {
valid: false,
error: 'timeLimit must be null or a number >= 30',
}
}
return {
valid: true,
newState: {
...state,
timeLimit: value as number | null,
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
}
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
}
private validateResumeGame(
state: CardSortingState,
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only resume from setup phase' }
}
private validateResumeGame(state: CardSortingState): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only resume from setup phase' }
}
// Must have paused game state
if (!state.pausedGamePhase || !state.pausedGameState) {
return { valid: false, error: 'No paused game to resume' }
}
// Must have paused game state
if (!state.pausedGamePhase || !state.pausedGameState) {
return { valid: false, error: 'No paused game to resume' }
}
// Restore paused state
return {
valid: true,
newState: {
...state,
gamePhase: state.pausedGamePhase,
selectedCards: state.pausedGameState.selectedCards,
correctOrder: [...state.pausedGameState.selectedCards].sort(
(a, b) => a.number - b.number,
),
availableCards: state.pausedGameState.availableCards,
placedCards: state.pausedGameState.placedCards,
gameStartTime: state.pausedGameState.gameStartTime,
numbersRevealed: state.pausedGameState.numbersRevealed,
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
}
// Restore paused state
return {
valid: true,
newState: {
...state,
gamePhase: state.pausedGamePhase,
selectedCards: state.pausedGameState.selectedCards,
correctOrder: [...state.pausedGameState.selectedCards].sort((a, b) => a.number - b.number),
availableCards: state.pausedGameState.availableCards,
placedCards: state.pausedGameState.placedCards,
gameStartTime: state.pausedGameState.gameStartTime,
numbersRevealed: state.pausedGameState.numbersRevealed,
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
}
isGameComplete(state: CardSortingState): boolean {
return state.gamePhase === 'results'
}
isGameComplete(state: CardSortingState): boolean {
return state.gamePhase === 'results'
}
getInitialState(config: CardSortingConfig): CardSortingState {
return {
cardCount: config.cardCount,
showNumbers: config.showNumbers,
timeLimit: config.timeLimit,
gamePhase: 'setup',
playerId: '',
playerMetadata: {
id: '',
name: '',
emoji: '',
userId: '',
},
gameStartTime: null,
gameEndTime: null,
selectedCards: [],
correctOrder: [],
availableCards: [],
placedCards: new Array(config.cardCount).fill(null),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
}
}
getInitialState(config: CardSortingConfig): CardSortingState {
return {
cardCount: config.cardCount,
showNumbers: config.showNumbers,
timeLimit: config.timeLimit,
gamePhase: 'setup',
playerId: '',
playerMetadata: {
id: '',
name: '',
emoji: '',
userId: '',
},
gameStartTime: null,
gameEndTime: null,
selectedCards: [],
correctOrder: [],
availableCards: [],
placedCards: new Array(config.cardCount).fill(null),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
}
}
}
export const cardSortingValidator = new CardSortingValidator()

View File

@@ -1,14 +1,82 @@
'use client'
/**
* Card Sorting Game Component
* TODO: Implement phase routing (setup, playing, results)
*/
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
import { StandardGameLayout } from '@/components/StandardGameLayout'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { useCardSorting } from '../Provider'
import { SetupPhase } from './SetupPhase'
import { PlayingPhase } from './PlayingPhase'
import { ResultsPhase } from './ResultsPhase'
export function GameComponent() {
return (
<div>
<h2>Card Sorting Challenge</h2>
<p>Coming soon...</p>
</div>
)
const router = useRouter()
const { state, exitSession, startGame, goToSetup } = useCardSorting()
const { setFullscreenElement } = useFullscreen()
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Register fullscreen element
if (gameRef.current) {
setFullscreenElement(gameRef.current)
}
}, [setFullscreenElement])
return (
<PageWithNav
navTitle="Card Sorting"
navEmoji="🔢"
emphasizePlayerSelection={state.gamePhase === 'setup'}
onExitSession={() => {
exitSession()
router.push('/arcade')
}}
onSetup={
goToSetup
? () => {
goToSetup()
}
: undefined
}
onNewGame={() => {
startGame()
}}
>
<StandardGameLayout>
<div
ref={gameRef}
className={css({
flex: 1,
padding: { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
overflow: 'auto',
})}
>
<main
className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
</div>
</StandardGameLayout>
</PageWithNav>
)
}

View File

@@ -0,0 +1,373 @@
'use client'
import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider'
export function PlayingPhase() {
const {
state,
selectedCardId,
selectCard,
placeCard,
removeCard,
checkSolution,
revealNumbers,
goToSetup,
canCheckSolution,
placedCount,
elapsedTime,
} = useCardSorting()
// Format time display
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
// Calculate gradient for position slots (darker = smaller, lighter = larger)
const getSlotGradient = (position: number, total: number) => {
const intensity = position / (total - 1 || 1)
const lightness = 30 + intensity * 45 // 30% to 75%
return {
background: `hsl(220, 8%, ${lightness}%)`,
color: lightness > 60 ? '#2c3e50' : '#ffffff',
}
}
const handleCardClick = (cardId: string) => {
if (selectedCardId === cardId) {
selectCard(null) // Deselect
} else {
selectCard(cardId)
}
}
const handleSlotClick = (position: number) => {
if (!selectedCardId) {
// No card selected - remove card if slot is occupied
if (state.placedCards[position]) {
removeCard(position)
}
} else {
// Card is selected - place it
placeCard(selectedCardId, position)
}
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
height: '100%',
})}
>
{/* Header with timer and actions */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1rem',
background: 'teal.50',
borderRadius: '0.5rem',
flexShrink: 0,
})}
>
<div className={css({ display: 'flex', gap: '2rem' })}>
<div>
<div
className={css({
fontSize: 'sm',
color: 'gray.600',
fontWeight: '600',
})}
>
Time
</div>
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'teal.700',
})}
>
{formatTime(elapsedTime)}
</div>
</div>
<div>
<div
className={css({
fontSize: 'sm',
color: 'gray.600',
fontWeight: '600',
})}
>
Progress
</div>
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'teal.700',
})}
>
{placedCount}/{state.cardCount}
</div>
</div>
</div>
<div className={css({ display: 'flex', gap: '0.5rem' })}>
{state.showNumbers && !state.numbersRevealed && (
<button
type="button"
onClick={revealNumbers}
className={css({
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
background: 'orange.500',
color: 'white',
fontSize: 'sm',
fontWeight: '600',
border: 'none',
cursor: 'pointer',
_hover: {
background: 'orange.600',
},
})}
>
Reveal Numbers
</button>
)}
<button
type="button"
onClick={checkSolution}
disabled={!canCheckSolution}
className={css({
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
background: canCheckSolution ? 'teal.600' : 'gray.300',
color: 'white',
fontSize: 'sm',
fontWeight: '600',
border: 'none',
cursor: canCheckSolution ? 'pointer' : 'not-allowed',
opacity: canCheckSolution ? 1 : 0.6,
_hover: {
background: canCheckSolution ? 'teal.700' : 'gray.300',
},
})}
>
Check Solution
</button>
<button
type="button"
onClick={goToSetup}
className={css({
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
background: 'gray.600',
color: 'white',
fontSize: 'sm',
fontWeight: '600',
border: 'none',
cursor: 'pointer',
_hover: {
background: 'gray.700',
},
})}
>
End Game
</button>
</div>
</div>
{/* Main game area */}
<div
className={css({
display: 'flex',
gap: '2rem',
flex: 1,
overflow: 'auto',
})}
>
{/* Available cards */}
<div className={css({ flex: 1, minWidth: '200px' })}>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '1rem',
color: 'gray.700',
})}
>
Available Cards
</h3>
<div
className={css({
display: 'grid',
gridTemplateColumns: {
base: '1',
sm: '2',
md: '3',
},
gap: '0.75rem',
})}
>
{state.availableCards.map((card) => (
<div
key={card.id}
onClick={() => handleCardClick(card.id)}
className={css({
padding: '0.5rem',
border: '2px solid',
borderColor: selectedCardId === card.id ? 'blue.500' : 'gray.300',
borderRadius: '0.5rem',
background: selectedCardId === card.id ? 'blue.50' : 'white',
cursor: 'pointer',
transition: 'all 0.2s',
transform: selectedCardId === card.id ? 'scale(1.05)' : 'scale(1)',
_hover: {
transform: 'scale(1.05)',
borderColor: 'blue.500',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
>
<div
dangerouslySetInnerHTML={{ __html: card.svgContent }}
className={css({
width: '100%',
'& svg': {
width: '100%',
height: 'auto',
},
})}
/>
{state.numbersRevealed && (
<div
className={css({
textAlign: 'center',
marginTop: '0.5rem',
fontSize: 'lg',
fontWeight: 'bold',
color: 'gray.700',
})}
>
{card.number}
</div>
)}
</div>
))}
</div>
</div>
{/* Position slots */}
<div className={css({ flex: 2, minWidth: '300px' })}>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '1rem',
color: 'gray.700',
})}
>
Sort Positions (Smallest Largest)
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{state.placedCards.map((card, index) => {
const gradientStyle = getSlotGradient(index, state.cardCount)
return (
<div
key={index}
onClick={() => handleSlotClick(index)}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
border: '2px solid',
borderColor:
gradientStyle.color === '#ffffff' ? 'rgba(255,255,255,0.4)' : '#2c5f76',
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: '1rem',
minHeight: '80px',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
style={gradientStyle}
>
<div
className={css({
fontSize: 'sm',
fontWeight: 'bold',
opacity: 0.7,
})}
>
#{index + 1}
</div>
{card ? (
<div
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
gap: '1rem',
})}
>
<div
dangerouslySetInnerHTML={{
__html: card.svgContent,
}}
className={css({
width: '120px',
'& svg': {
width: '100%',
height: 'auto',
},
})}
/>
{state.numbersRevealed && (
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
})}
>
{card.number}
</div>
)}
</div>
) : (
<div
className={css({
flex: 1,
fontSize: 'sm',
opacity: 0.5,
fontStyle: 'italic',
})}
>
{selectedCardId ? 'Click to place card' : 'Empty'}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,436 @@
'use client'
import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider'
export function ResultsPhase() {
const { state, startGame, goToSetup, exitSession } = useCardSorting()
const { scoreBreakdown } = state
if (!scoreBreakdown) {
return (
<div className={css({ textAlign: 'center', padding: '2rem' })}>
<p>No score data available</p>
</div>
)
}
const getMessage = (score: number) => {
if (score === 100) return '🎉 Perfect! All cards in correct order!'
if (score >= 80) return '👍 Excellent! Very close to perfect!'
if (score >= 60) return '👍 Good job! You understand the pattern!'
return '💪 Keep practicing! Focus on reading each abacus carefully.'
}
const getEmoji = (score: number) => {
if (score === 100) return '🏆'
if (score >= 80) return '⭐'
if (score >= 60) return '👍'
return '📈'
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2rem',
padding: '1rem',
overflow: 'auto',
})}
>
{/* Score Display */}
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '4rem', marginBottom: '0.5rem' })}>
{getEmoji(scoreBreakdown.finalScore)}
</div>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
marginBottom: '0.5rem',
color: 'gray.800',
})}
>
Your Score: {scoreBreakdown.finalScore}%
</h2>
<p className={css({ fontSize: 'lg', color: 'gray.600' })}>
{getMessage(scoreBreakdown.finalScore)}
</p>
</div>
{/* Score Breakdown */}
<div
className={css({
background: 'white',
borderRadius: '0.75rem',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
})}
>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
marginBottom: '1rem',
color: 'gray.800',
})}
>
Score Breakdown
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1rem' })}>
{/* Exact Position Matches */}
<div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.25rem',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>
Exact Position Matches (30%)
</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{scoreBreakdown.exactMatches}/{state.cardCount} cards
</span>
</div>
<div
className={css({
width: '100%',
height: '1.5rem',
background: 'gray.200',
borderRadius: '9999px',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
background: 'teal.500',
transition: 'width 0.5s ease',
})}
style={{ width: `${scoreBreakdown.exactPositionScore}%` }}
/>
</div>
</div>
{/* Relative Order */}
<div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.25rem',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>
Relative Order (50%)
</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{scoreBreakdown.lcsLength}/{state.cardCount} in sequence
</span>
</div>
<div
className={css({
width: '100%',
height: '1.5rem',
background: 'gray.200',
borderRadius: '9999px',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
background: 'teal.500',
transition: 'width 0.5s ease',
})}
style={{ width: `${scoreBreakdown.relativeOrderScore}%` }}
/>
</div>
</div>
{/* Organization */}
<div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.25rem',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>Organization (20%)</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{scoreBreakdown.inversions} out-of-order pairs
</span>
</div>
<div
className={css({
width: '100%',
height: '1.5rem',
background: 'gray.200',
borderRadius: '9999px',
overflow: 'hidden',
})}
>
<div
className={css({
height: '100%',
background: 'teal.500',
transition: 'width 0.5s ease',
})}
style={{ width: `${scoreBreakdown.inversionScore}%` }}
/>
</div>
</div>
{/* Time Taken */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
paddingTop: '0.5rem',
borderTop: '1px solid',
borderColor: 'gray.200',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: '600' })}>Time Taken</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{Math.floor(scoreBreakdown.elapsedTime / 60)}:
{(scoreBreakdown.elapsedTime % 60).toString().padStart(2, '0')}
</span>
</div>
{scoreBreakdown.numbersRevealed && (
<div
className={css({
padding: '0.75rem',
background: 'orange.50',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'orange.200',
fontSize: 'sm',
color: 'orange.700',
textAlign: 'center',
})}
>
Numbers were revealed during play
</div>
)}
</div>
</div>
{/* Comparison */}
<div
className={css({
background: 'white',
borderRadius: '0.75rem',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
})}
>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
marginBottom: '1rem',
color: 'gray.800',
})}
>
Comparison
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1.5rem' })}>
{/* User's Answer */}
<div>
<h4
className={css({
fontSize: 'md',
fontWeight: '600',
marginBottom: '0.5rem',
color: 'gray.700',
})}
>
Your Answer:
</h4>
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
{state.placedCards.map((card, i) => {
if (!card) return null
const isCorrect = card.number === state.correctOrder[i]?.number
return (
<div
key={i}
className={css({
padding: '0.5rem',
border: '2px solid',
borderColor: isCorrect ? 'green.500' : 'red.500',
borderRadius: '0.375rem',
background: isCorrect ? 'green.50' : 'red.50',
textAlign: 'center',
minWidth: '60px',
})}
>
<div
className={css({
fontSize: 'xs',
color: 'gray.600',
marginBottom: '0.25rem',
})}
>
#{i + 1}
</div>
<div
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: isCorrect ? 'green.700' : 'red.700',
})}
>
{card.number}
</div>
{isCorrect ? (
<div className={css({ fontSize: 'xs' })}></div>
) : (
<div className={css({ fontSize: 'xs' })}></div>
)}
</div>
)
})}
</div>
</div>
{/* Correct Order */}
<div>
<h4
className={css({
fontSize: 'md',
fontWeight: '600',
marginBottom: '0.5rem',
color: 'gray.700',
})}
>
Correct Order:
</h4>
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
{state.correctOrder.map((card, i) => (
<div
key={i}
className={css({
padding: '0.5rem',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '0.375rem',
background: 'gray.50',
textAlign: 'center',
minWidth: '60px',
})}
>
<div
className={css({
fontSize: 'xs',
color: 'gray.600',
marginBottom: '0.25rem',
})}
>
#{i + 1}
</div>
<div
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: 'gray.700',
})}
>
{card.number}
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
maxWidth: '400px',
margin: '0 auto',
width: '100%',
})}
>
<button
type="button"
onClick={startGame}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
background: 'teal.600',
color: 'white',
fontWeight: '600',
fontSize: 'lg',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
background: 'teal.700',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
>
New Game (Same Settings)
</button>
<button
type="button"
onClick={goToSetup}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
background: 'gray.600',
color: 'white',
fontWeight: '600',
fontSize: 'lg',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
background: 'gray.700',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
>
Change Settings
</button>
<button
type="button"
onClick={exitSession}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
background: 'white',
color: 'gray.700',
fontWeight: '600',
fontSize: 'lg',
border: '2px solid',
borderColor: 'gray.300',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'gray.400',
background: 'gray.50',
},
})}
>
Exit to Room
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider'
export function SetupPhase() {
const { state, setConfig, startGame, resumeGame, canResumeGame } = useCardSorting()
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2rem',
padding: '2rem',
})}
>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
marginBottom: '0.5rem',
})}
>
Card Sorting Challenge
</h2>
<p
className={css({
fontSize: { base: 'md', md: 'lg' },
color: 'gray.600',
})}
>
Arrange abacus cards in order using only visual patterns
</p>
</div>
{/* Card Count Selection */}
<div className={css({ width: '100%', maxWidth: '400px' })}>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '0.5rem',
color: 'gray.700',
})}
>
Number of Cards
</label>
<div
className={css({
display: 'grid',
gridTemplateColumns: '4',
gap: '0.5rem',
})}
>
{([5, 8, 12, 15] as const).map((count) => (
<button
key={count}
type="button"
onClick={() => setConfig('cardCount', count)}
className={css({
padding: '0.75rem',
borderRadius: '0.5rem',
border: '2px solid',
borderColor: state.cardCount === count ? 'teal.500' : 'gray.300',
background: state.cardCount === count ? 'teal.50' : 'white',
color: state.cardCount === count ? 'teal.700' : 'gray.700',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'teal.400',
background: 'teal.50',
},
})}
>
{count}
</button>
))}
</div>
</div>
{/* Show Numbers Toggle */}
<div className={css({ width: '100%', maxWidth: '400px' })}>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '1rem',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '0.5rem',
cursor: 'pointer',
_hover: {
background: 'gray.50',
},
})}
>
<input
type="checkbox"
checked={state.showNumbers}
onChange={(e) => setConfig('showNumbers', e.target.checked)}
className={css({
width: '1.25rem',
height: '1.25rem',
cursor: 'pointer',
})}
/>
<div>
<div
className={css({
fontWeight: '600',
color: 'gray.700',
})}
>
Allow "Reveal Numbers" button
</div>
<div
className={css({
fontSize: 'sm',
color: 'gray.500',
})}
>
Show numeric values during gameplay
</div>
</div>
</label>
</div>
{/* Action Buttons */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
width: '100%',
maxWidth: '400px',
marginTop: '1rem',
})}
>
{canResumeGame && (
<button
type="button"
onClick={resumeGame}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
background: 'teal.600',
color: 'white',
fontWeight: '600',
fontSize: 'lg',
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s',
_hover: {
background: 'teal.700',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
>
Resume Game
</button>
)}
<button
type="button"
onClick={startGame}
className={css({
padding: '1rem',
borderRadius: '0.5rem',
background: canResumeGame ? 'gray.600' : 'teal.600',
color: 'white',
fontWeight: '600',
fontSize: 'lg',
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s',
_hover: {
background: canResumeGame ? 'gray.700' : 'teal.700',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
>
{canResumeGame ? 'Start New Game' : 'Start Game'}
</button>
</div>
</div>
)
}

View File

@@ -13,71 +13,62 @@ import type { CardSortingConfig, CardSortingMove, CardSortingState } from './typ
import { cardSortingValidator } from './Validator'
const manifest: GameManifest = {
name: 'card-sorting',
displayName: 'Card Sorting Challenge',
icon: '🔢',
description: 'Sort abacus cards using pattern recognition',
longDescription:
'Challenge your abacus reading skills! Arrange cards in ascending order using only ' +
'the visual patterns - no numbers shown. Perfect for practicing number recognition and ' +
'developing mental math intuition.',
maxPlayers: 1, // Single player only
difficulty: 'Intermediate',
chips: ['🧠 Pattern Recognition', '🎯 Solo Challenge', '📊 Smart Scoring'],
color: 'teal',
gradient: 'linear-gradient(135deg, #99f6e4, #5eead4)',
borderColor: 'teal.200',
available: true,
name: 'card-sorting',
displayName: 'Card Sorting Challenge',
icon: '🔢',
description: 'Sort abacus cards using pattern recognition',
longDescription:
'Challenge your abacus reading skills! Arrange cards in ascending order using only ' +
'the visual patterns - no numbers shown. Perfect for practicing number recognition and ' +
'developing mental math intuition.',
maxPlayers: 1, // Single player only
difficulty: 'Intermediate',
chips: ['🧠 Pattern Recognition', '🎯 Solo Challenge', '📊 Smart Scoring'],
color: 'teal',
gradient: 'linear-gradient(135deg, #99f6e4, #5eead4)',
borderColor: 'teal.200',
available: true,
}
const defaultConfig: CardSortingConfig = {
cardCount: 8,
showNumbers: true,
timeLimit: null,
cardCount: 8,
showNumbers: true,
timeLimit: null,
}
// Config validation function
function validateCardSortingConfig(
config: unknown,
): config is CardSortingConfig {
if (typeof config !== 'object' || config === null) {
return false
}
function validateCardSortingConfig(config: unknown): config is CardSortingConfig {
if (typeof config !== 'object' || config === null) {
return false
}
const c = config as Record<string, unknown>
const c = config as Record<string, unknown>
// Validate cardCount
if (!('cardCount' in c) || ![5, 8, 12, 15].includes(c.cardCount as number)) {
return false
}
// Validate cardCount
if (!('cardCount' in c) || ![5, 8, 12, 15].includes(c.cardCount as number)) {
return false
}
// Validate showNumbers
if (!('showNumbers' in c) || typeof c.showNumbers !== 'boolean') {
return false
}
// Validate showNumbers
if (!('showNumbers' in c) || typeof c.showNumbers !== 'boolean') {
return false
}
// Validate timeLimit
if ('timeLimit' in c) {
if (
c.timeLimit !== null &&
(typeof c.timeLimit !== 'number' || c.timeLimit < 30)
) {
return false
}
}
// Validate timeLimit
if ('timeLimit' in c) {
if (c.timeLimit !== null && (typeof c.timeLimit !== 'number' || c.timeLimit < 30)) {
return false
}
}
return true
return true
}
export const cardSortingGame = defineGame<
CardSortingConfig,
CardSortingState,
CardSortingMove
>({
manifest,
Provider: CardSortingProvider,
GameComponent,
validator: cardSortingValidator,
defaultConfig,
validateConfig: validateCardSortingConfig,
export const cardSortingGame = defineGame<CardSortingConfig, CardSortingState, CardSortingMove>({
manifest,
Provider: CardSortingProvider,
GameComponent,
validator: cardSortingValidator,
defaultConfig,
validateConfig: validateCardSortingConfig,
})

View File

@@ -5,10 +5,10 @@ import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
// ============================================================================
export interface PlayerMetadata {
id: string // Player ID (UUID)
name: string
emoji: string
userId: string
id: string // Player ID (UUID)
name: string
emoji: string
userId: string
}
// ============================================================================
@@ -16,9 +16,9 @@ export interface PlayerMetadata {
// ============================================================================
export interface CardSortingConfig extends GameConfig {
cardCount: 5 | 8 | 12 | 15 // Difficulty (number of cards)
showNumbers: boolean // Allow reveal numbers button
timeLimit: number | null // Optional time limit (seconds), null = unlimited
cardCount: 5 | 8 | 12 | 15 // Difficulty (number of cards)
showNumbers: boolean // Allow reveal numbers button
timeLimit: number | null // Optional time limit (seconds), null = unlimited
}
// ============================================================================
@@ -28,26 +28,26 @@ export interface CardSortingConfig extends GameConfig {
export type GamePhase = 'setup' | 'playing' | 'results'
export interface SortingCard {
id: string // Unique ID for this card instance
number: number // The abacus value (0-99+)
svgContent: string // Serialized AbacusReact SVG
id: string // Unique ID for this card instance
number: number // The abacus value (0-99+)
svgContent: string // Serialized AbacusReact SVG
}
export interface PlacedCard {
card: SortingCard // The card data
position: number // Which slot it's in (0-indexed)
card: SortingCard // The card data
position: number // Which slot it's in (0-indexed)
}
export interface ScoreBreakdown {
finalScore: number // 0-100 weighted average
exactMatches: number // Cards in exactly correct position
lcsLength: number // Longest common subsequence length
inversions: number // Number of out-of-order pairs
relativeOrderScore: number // 0-100 based on LCS
exactPositionScore: number // 0-100 based on exact matches
inversionScore: number // 0-100 based on inversions
elapsedTime: number // Seconds taken
numbersRevealed: boolean // Whether player used reveal
finalScore: number // 0-100 weighted average
exactMatches: number // Cards in exactly correct position
lcsLength: number // Longest common subsequence length
inversions: number // Number of out-of-order pairs
relativeOrderScore: number // 0-100 based on LCS
exactPositionScore: number // 0-100 based on exact matches
inversionScore: number // 0-100 based on inversions
elapsedTime: number // Seconds taken
numbersRevealed: boolean // Whether player used reveal
}
// ============================================================================
@@ -55,43 +55,43 @@ export interface ScoreBreakdown {
// ============================================================================
export interface CardSortingState extends GameState {
// Configuration
cardCount: 5 | 8 | 12 | 15
showNumbers: boolean
timeLimit: number | null
// Configuration
cardCount: 5 | 8 | 12 | 15
showNumbers: boolean
timeLimit: number | null
// Game phase
gamePhase: GamePhase
// Game phase
gamePhase: GamePhase
// Player & timing
playerId: string // Single player ID
playerMetadata: PlayerMetadata // Player display info
gameStartTime: number | null
gameEndTime: number | null
// Player & timing
playerId: string // Single player ID
playerMetadata: PlayerMetadata // Player display info
gameStartTime: number | null
gameEndTime: number | null
// Cards
selectedCards: SortingCard[] // The N cards for this game
correctOrder: SortingCard[] // Sorted by number (answer key)
availableCards: SortingCard[] // Cards not yet placed
placedCards: (SortingCard | null)[] // Array of N slots (null = empty)
// Cards
selectedCards: SortingCard[] // The N cards for this game
correctOrder: SortingCard[] // Sorted by number (answer key)
availableCards: SortingCard[] // Cards not yet placed
placedCards: (SortingCard | null)[] // Array of N slots (null = empty)
// UI state (client-only, not in server state)
selectedCardId: string | null // Currently selected card
numbersRevealed: boolean // If player revealed numbers
// UI state (client-only, not in server state)
selectedCardId: string | null // Currently selected card
numbersRevealed: boolean // If player revealed numbers
// Results
scoreBreakdown: ScoreBreakdown | null // Final score details
// Results
scoreBreakdown: ScoreBreakdown | null // Final score details
// Pause/Resume (standard pattern)
originalConfig?: CardSortingConfig
pausedGamePhase?: GamePhase
pausedGameState?: {
selectedCards: SortingCard[]
availableCards: SortingCard[]
placedCards: (SortingCard | null)[]
gameStartTime: number
numbersRevealed: boolean
}
// Pause/Resume (standard pattern)
originalConfig?: CardSortingConfig
pausedGamePhase?: GamePhase
pausedGameState?: {
selectedCards: SortingCard[]
availableCards: SortingCard[]
placedCards: (SortingCard | null)[]
gameStartTime: number
numbersRevealed: boolean
}
}
// ============================================================================
@@ -99,100 +99,100 @@ export interface CardSortingState extends GameState {
// ============================================================================
export type CardSortingMove =
| {
type: 'START_GAME'
playerId: string
userId: string
timestamp: number
data: {
playerMetadata: PlayerMetadata
selectedCards: SortingCard[] // Pre-selected random cards
}
}
| {
type: 'PLACE_CARD'
playerId: string
userId: string
timestamp: number
data: {
cardId: string // Which card to place
position: number // Which slot (0-indexed)
}
}
| {
type: 'REMOVE_CARD'
playerId: string
userId: string
timestamp: number
data: {
position: number // Which slot to remove from
}
}
| {
type: 'REVEAL_NUMBERS'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'CHECK_SOLUTION'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'GO_TO_SETUP'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_CONFIG'
playerId: string
userId: string
timestamp: number
data: {
field: 'cardCount' | 'showNumbers' | 'timeLimit'
value: unknown
}
}
| {
type: 'RESUME_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'START_GAME'
playerId: string
userId: string
timestamp: number
data: {
playerMetadata: PlayerMetadata
selectedCards: SortingCard[] // Pre-selected random cards
}
}
| {
type: 'PLACE_CARD'
playerId: string
userId: string
timestamp: number
data: {
cardId: string // Which card to place
position: number // Which slot (0-indexed)
}
}
| {
type: 'REMOVE_CARD'
playerId: string
userId: string
timestamp: number
data: {
position: number // Which slot to remove from
}
}
| {
type: 'REVEAL_NUMBERS'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'CHECK_SOLUTION'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'GO_TO_SETUP'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_CONFIG'
playerId: string
userId: string
timestamp: number
data: {
field: 'cardCount' | 'showNumbers' | 'timeLimit'
value: unknown
}
}
| {
type: 'RESUME_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
// ============================================================================
// Component Props
// ============================================================================
export interface SortingCardProps {
card: SortingCard
isSelected: boolean
isPlaced: boolean
isCorrect?: boolean // After checking solution
onClick: () => void
showNumber: boolean // If revealed
card: SortingCard
isSelected: boolean
isPlaced: boolean
isCorrect?: boolean // After checking solution
onClick: () => void
showNumber: boolean // If revealed
}
export interface PositionSlotProps {
position: number
card: SortingCard | null
isActive: boolean // If slot is clickable
isCorrect?: boolean // After checking solution
gradientStyle: React.CSSProperties
onClick: () => void
position: number
card: SortingCard | null
isActive: boolean // If slot is clickable
isCorrect?: boolean // After checking solution
gradientStyle: React.CSSProperties
onClick: () => void
}
export interface ScoreDisplayProps {
breakdown: ScoreBreakdown
correctOrder: SortingCard[]
userOrder: SortingCard[]
onNewGame: () => void
onExit: () => void
breakdown: ScoreBreakdown
correctOrder: SortingCard[]
userOrder: SortingCard[]
onNewGame: () => void
onExit: () => void
}

View File

@@ -8,44 +8,47 @@ import type { SortingCard } from '../types'
* @param minValue Minimum abacus value (default 0)
* @param maxValue Maximum abacus value (default 99)
*/
export function generateRandomCards(
count: number,
minValue = 0,
maxValue = 99,
): SortingCard[] {
// Generate pool of unique random numbers
const numbers = new Set<number>()
while (numbers.size < count) {
const num = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
numbers.add(num)
}
export function generateRandomCards(count: number, minValue = 0, maxValue = 99): SortingCard[] {
// Generate pool of unique random numbers
const numbers = new Set<number>()
while (numbers.size < count) {
const num = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
numbers.add(num)
}
// Convert to sorted array (for answer key)
const sortedNumbers = Array.from(numbers).sort((a, b) => a - b)
// Convert to sorted array (for answer key)
const sortedNumbers = Array.from(numbers).sort((a, b) => a - b)
// Create card objects with SVG content
return sortedNumbers.map((number, index) => {
// Render AbacusReact to SVG string
const svgContent = renderToString(
<AbacusReact value={number} width={200} height={120} />,
)
// Create card objects with SVG content
return sortedNumbers.map((number, index) => {
// Render AbacusReact to SVG string
const svgContent = renderToString(
<AbacusReact
value={number}
columns="auto"
scaleFactor={1.0}
interactive={false}
showNumbers={false}
animated={false}
/>
)
return {
id: `card-${index}-${number}`,
number,
svgContent,
}
})
return {
id: `card-${index}-${number}`,
number,
svgContent,
}
})
}
/**
* Shuffle array for random order
*/
export function shuffleCards(cards: SortingCard[]): SortingCard[] {
const shuffled = [...cards]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
const shuffled = [...cards]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}

View File

@@ -4,106 +4,97 @@ import type { ScoreBreakdown } from '../types'
* Calculate Longest Common Subsequence length
* Measures how many cards are in correct relative order
*/
export function longestCommonSubsequence(
seq1: number[],
seq2: number[],
): number {
const m = seq1.length
const n = seq2.length
const dp: number[][] = Array(m + 1)
.fill(0)
.map(() => Array(n + 1).fill(0))
export function longestCommonSubsequence(seq1: number[], seq2: number[]): number {
const m = seq1.length
const n = seq2.length
const dp: number[][] = Array(m + 1)
.fill(0)
.map(() => Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (seq1[i - 1] === seq2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (seq1[i - 1] === seq2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
return dp[m][n]
return dp[m][n]
}
/**
* Count inversions (out-of-order pairs)
* Measures how scrambled the sequence is
*/
export function countInversions(
userSeq: number[],
correctSeq: number[],
): number {
// Create mapping from value to correct position
const correctPositions: Record<number, number> = {}
for (let idx = 0; idx < correctSeq.length; idx++) {
correctPositions[correctSeq[idx]] = idx
}
export function countInversions(userSeq: number[], correctSeq: number[]): number {
// Create mapping from value to correct position
const correctPositions: Record<number, number> = {}
for (let idx = 0; idx < correctSeq.length; idx++) {
correctPositions[correctSeq[idx]] = idx
}
// Convert user sequence to correct-position sequence
const userCorrectPositions = userSeq.map((val) => correctPositions[val])
// Convert user sequence to correct-position sequence
const userCorrectPositions = userSeq.map((val) => correctPositions[val])
// Count inversions
let inversions = 0
for (let i = 0; i < userCorrectPositions.length; i++) {
for (let j = i + 1; j < userCorrectPositions.length; j++) {
if (userCorrectPositions[i] > userCorrectPositions[j]) {
inversions++
}
}
}
// Count inversions
let inversions = 0
for (let i = 0; i < userCorrectPositions.length; i++) {
for (let j = i + 1; j < userCorrectPositions.length; j++) {
if (userCorrectPositions[i] > userCorrectPositions[j]) {
inversions++
}
}
}
return inversions
return inversions
}
/**
* Calculate comprehensive score breakdown
*/
export function calculateScore(
userSequence: number[],
correctSequence: number[],
startTime: number,
numbersRevealed: boolean,
userSequence: number[],
correctSequence: number[],
startTime: number,
numbersRevealed: boolean
): ScoreBreakdown {
// LCS-based score (relative order)
const lcsLength = longestCommonSubsequence(userSequence, correctSequence)
const relativeOrderScore = (lcsLength / correctSequence.length) * 100
// LCS-based score (relative order)
const lcsLength = longestCommonSubsequence(userSequence, correctSequence)
const relativeOrderScore = (lcsLength / correctSequence.length) * 100
// Exact position matches
let exactMatches = 0
for (let i = 0; i < userSequence.length; i++) {
if (userSequence[i] === correctSequence[i]) {
exactMatches++
}
}
const exactPositionScore = (exactMatches / correctSequence.length) * 100
// Exact position matches
let exactMatches = 0
for (let i = 0; i < userSequence.length; i++) {
if (userSequence[i] === correctSequence[i]) {
exactMatches++
}
}
const exactPositionScore = (exactMatches / correctSequence.length) * 100
// Inversion-based score (organization)
const inversions = countInversions(userSequence, correctSequence)
const maxInversions = (correctSequence.length * (correctSequence.length - 1)) / 2
const inversionScore = Math.max(
0,
((maxInversions - inversions) / maxInversions) * 100,
)
// Inversion-based score (organization)
const inversions = countInversions(userSequence, correctSequence)
const maxInversions = (correctSequence.length * (correctSequence.length - 1)) / 2
const inversionScore = Math.max(0, ((maxInversions - inversions) / maxInversions) * 100)
// Weighted final score
// - 50% for relative order (LCS)
// - 30% for exact positions
// - 20% for organization (inversions)
const finalScore = Math.round(
relativeOrderScore * 0.5 + exactPositionScore * 0.3 + inversionScore * 0.2,
)
// Weighted final score
// - 50% for relative order (LCS)
// - 30% for exact positions
// - 20% for organization (inversions)
const finalScore = Math.round(
relativeOrderScore * 0.5 + exactPositionScore * 0.3 + inversionScore * 0.2
)
return {
finalScore,
exactMatches,
lcsLength,
inversions,
relativeOrderScore: Math.round(relativeOrderScore),
exactPositionScore: Math.round(exactPositionScore),
inversionScore: Math.round(inversionScore),
elapsedTime: Math.floor((Date.now() - startTime) / 1000),
numbersRevealed,
}
return {
finalScore,
exactMatches,
lcsLength,
inversions,
relativeOrderScore: Math.round(relativeOrderScore),
exactPositionScore: Math.round(exactPositionScore),
inversionScore: Math.round(inversionScore),
elapsedTime: Math.floor((Date.now() - startTime) / 1000),
numbersRevealed,
}
}

View File

@@ -5,78 +5,78 @@ import type { SortingCard } from '../types'
* Returns new placedCards array with no gaps
*/
export function placeCardAtPosition(
placedCards: (SortingCard | null)[],
cardToPlace: SortingCard,
position: number,
totalSlots: number,
placedCards: (SortingCard | null)[],
cardToPlace: SortingCard,
position: number,
totalSlots: number
): { placedCards: (SortingCard | null)[]; excessCards: SortingCard[] } {
// Create working array
const newPlaced = new Array(totalSlots).fill(null)
// Create working array
const newPlaced = new Array(totalSlots).fill(null)
// Copy existing cards, shifting those at/after position
for (let i = 0; i < placedCards.length; i++) {
if (placedCards[i] !== null) {
if (i < position) {
// Before insert position - stays same
newPlaced[i] = placedCards[i]
} else {
// At or after position - shift right
if (i + 1 < totalSlots) {
newPlaced[i + 1] = placedCards[i]
}
}
}
}
// Copy existing cards, shifting those at/after position
for (let i = 0; i < placedCards.length; i++) {
if (placedCards[i] !== null) {
if (i < position) {
// Before insert position - stays same
newPlaced[i] = placedCards[i]
} else {
// At or after position - shift right
if (i + 1 < totalSlots) {
newPlaced[i + 1] = placedCards[i]
}
}
}
}
// Place new card
newPlaced[position] = cardToPlace
// Place new card
newPlaced[position] = cardToPlace
// Compact to remove gaps (shift all cards left)
const compacted: SortingCard[] = []
for (const card of newPlaced) {
if (card !== null) {
compacted.push(card)
}
}
// Compact to remove gaps (shift all cards left)
const compacted: SortingCard[] = []
for (const card of newPlaced) {
if (card !== null) {
compacted.push(card)
}
}
// Fill final array
const result = new Array(totalSlots).fill(null)
for (let i = 0; i < Math.min(compacted.length, totalSlots); i++) {
result[i] = compacted[i]
}
// Fill final array
const result = new Array(totalSlots).fill(null)
for (let i = 0; i < Math.min(compacted.length, totalSlots); i++) {
result[i] = compacted[i]
}
// Any excess cards are returned (shouldn't happen)
const excess = compacted.slice(totalSlots)
// Any excess cards are returned (shouldn't happen)
const excess = compacted.slice(totalSlots)
return { placedCards: result, excessCards: excess }
return { placedCards: result, excessCards: excess }
}
/**
* Remove card at position
*/
export function removeCardAtPosition(
placedCards: (SortingCard | null)[],
position: number,
placedCards: (SortingCard | null)[],
position: number
): { placedCards: (SortingCard | null)[]; removedCard: SortingCard | null } {
const removedCard = placedCards[position]
const removedCard = placedCards[position]
if (!removedCard) {
return { placedCards, removedCard: null }
}
if (!removedCard) {
return { placedCards, removedCard: null }
}
// Remove card and compact
const compacted: SortingCard[] = []
for (let i = 0; i < placedCards.length; i++) {
if (i !== position && placedCards[i] !== null) {
compacted.push(placedCards[i] as SortingCard)
}
}
// Remove card and compact
const compacted: SortingCard[] = []
for (let i = 0; i < placedCards.length; i++) {
if (i !== position && placedCards[i] !== null) {
compacted.push(placedCards[i] as SortingCard)
}
}
// Fill new array
const newPlaced = new Array(placedCards.length).fill(null)
for (let i = 0; i < compacted.length; i++) {
newPlaced[i] = compacted[i]
}
// Fill new array
const newPlaced = new Array(placedCards.length).fill(null)
for (let i = 0; i < compacted.length; i++) {
newPlaced[i] = compacted[i]
}
return { placedCards: newPlaced, removedCard }
return { placedCards: newPlaced, removedCard }
}