Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release-bot
c93a1b3074 chore(release): 4.9.0 [skip ci]
## [4.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.8.0...v4.9.0) (2025-10-18)

### Features

* **card-sorting:** implement Provider with arcade session integration ([7f6fea9](7f6fea91f6))
2025-10-18 19:13:00 +00:00
Thomas Hallock
7f6fea91f6 feat(card-sorting): implement Provider with arcade session integration
Add complete Provider implementation for Card Sorting Challenge game.

Features:
- Full useArcadeSession integration with optimistic updates
- Config persistence to database (gameConfig.card-sorting)
- Single-player pattern (local player selection)
- Pause/resume support with config change detection
- Action creators for all 8 move types:
  * startGame() - generates random cards
  * placeCard(cardId, position) - place card in slot
  * removeCard(position) - return card to available
  * checkSolution() - validate answer & calculate score
  * revealNumbers() - show numeric values
  * goToSetup() - pause/return to setup
  * resumeGame() - restore paused game
  * setConfig() - update game settings
- Computed values: canCheckSolution, placedCount, elapsedTime
- Local UI state: selectedCardId (not synced)
- useCardSorting() hook for component access

Next: UI components (Setup, Playing, Results)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 14:11:52 -05:00
semantic-release-bot
aaa253bde0 chore(release): 4.8.0 [skip ci]
## [4.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.1...v4.8.0) (2025-10-18)

### Features

* **arcade:** add Card Sorting Challenge game scaffolding ([df37260](df37260e26))
2025-10-18 19:05:53 +00:00
Thomas Hallock
df37260e26 feat(arcade): add Card Sorting Challenge game scaffolding
Add complete scaffolding for single-player card sorting pattern recognition game.

Architecture:
- Type-safe game config, state, and move definitions
- Server-side validator with 8 move types (START_GAME, PLACE_CARD, REMOVE_CARD, etc.)
- LCS-based scoring algorithm (50% relative order + 30% exact + 20% inversions)
- Card generation using AbacusReact SSR
- Array compaction logic for gap-free card placement

Features:
- Variable difficulty (5, 8, 12, 15 cards)
- Optional number reveal
- Pause/resume support
- Comprehensive score breakdown

Next: Implement Provider and UI components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 14:04:39 -05:00
18 changed files with 3297 additions and 14 deletions

View File

@@ -1,3 +1,17 @@
## [4.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.8.0...v4.9.0) (2025-10-18)
### Features
* **card-sorting:** implement Provider with arcade session integration ([7f6fea9](https://github.com/antialias/soroban-abacus-flashcards/commit/7f6fea91f6dcc69a173eea86bcefc9921f1c1664))
## [4.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.1...v4.8.0) (2025-10-18)
### Features
* **arcade:** add Card Sorting Challenge game scaffolding ([df37260](https://github.com/antialias/soroban-abacus-flashcards/commit/df37260e26bbb146493e0834e093afd98fa3f2a4))
## [4.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.0...v4.7.1) (2025-10-18)

View File

@@ -94,7 +94,10 @@
"Bash(! echo \"$file\")",
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)",
"Bash(pnpm install)",
"Bash(pnpm exec turbo build --filter=@soroban/web)"
"Bash(pnpm exec turbo build --filter=@soroban/web)",
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
"Bash(do gh run list --limit 1 --json conclusion,status,name --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - \\(.name)\"\"')",
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')"
],
"deny": [],
"ask": []

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,525 @@
'use client'
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
createContext,
useContext,
useState,
} from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { buildPlayerMetadata as buildPlayerMetadataUtil } from '@/lib/arcade/player-ownership.client'
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'
// 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
}
// Create context
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,
})
/**
* Optimistic move application (client-side prediction)
*/
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)
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
// 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,
}
}
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]
return {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
}
}
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 '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,
}
}
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,
}
: {}),
}
}
case 'RESUME_GAME': {
if (!state.pausedGamePhase || !state.pausedGameState) {
return state
}
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,
}
}
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()
// 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])
// 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])
// 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: '',
}
}
const playerOwnership: Record<string, string> = {}
if (viewerId) {
playerOwnership[localPlayerId] = viewerId
}
const metadata = buildPlayerMetadataUtil(
[localPlayerId],
playerOwnership,
players,
viewerId ?? undefined,
)
return metadata[localPlayerId] || { id: '', name: '', emoji: '', userId: '' }
}, [localPlayerId, players, viewerId])
// 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 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 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
}
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,
])
const placeCard = useCallback(
(cardId: string, position: number) => {
if (!localPlayerId) return
sendMove({
type: 'PLACE_CARD',
playerId: localPlayerId,
userId: viewerId || '',
data: { cardId, position },
})
// Clear selection
setSelectedCardId(null)
},
[localPlayerId, sendMove, viewerId],
)
const removeCard = useCallback(
(position: number) => {
if (!localPlayerId) return
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
}
sendMove({
type: 'CHECK_SOLUTION',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canCheckSolution, sendMove, viewerId])
const revealNumbers = useCallback(() => {
if (!localPlayerId) return
sendMove({
type: 'REVEAL_NUMBERS',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, sendMove, viewerId])
const goToSetup = useCallback(() => {
if (!localPlayerId) return
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
}
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
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>) || {}
const updatedConfig = {
...currentGameConfig,
'card-sorting': {
...currentCardSortingConfig,
[field]: value,
},
}
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,
}
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
}

View File

@@ -0,0 +1,425 @@
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}`,
}
}
}
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' }
}
if (data.selectedCards.length !== state.cardCount) {
return {
valid: false,
error: `Must provide exactly ${state.cardCount} cards`,
}
}
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
})
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' }
}
// 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}`,
}
}
// 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)
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',
}
}
// 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' }
}
// Remove the card using utility function
const { placedCards: newPlaced, removedCard } = removeCardAtPosition(
state.placedCards,
position,
)
if (!removedCard) {
return { valid: false, error: 'Failed to remove card' }
}
// Add back to available
const newAvailable = [...state.availableCards, removedCard]
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',
}
}
// 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' }
}
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',
}
}
// 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)
const scoreBreakdown = calculateScore(
userSequence,
correctSequence,
state.gameStartTime || Date.now(),
state.numbersRevealed,
)
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,
},
},
}
}
// 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' }
}
// 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 '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}` }
}
}
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' }
}
// 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'
}
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

@@ -0,0 +1,14 @@
'use client'
/**
* Card Sorting Game Component
* TODO: Implement phase routing (setup, playing, results)
*/
export function GameComponent() {
return (
<div>
<h2>Card Sorting Challenge</h2>
<p>Coming soon...</p>
</div>
)
}

View File

@@ -0,0 +1,83 @@
/**
* Card Sorting Challenge Game Definition
*
* A single-player pattern recognition game where players arrange abacus cards
* in ascending order using only visual patterns (no numbers shown).
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { CardSortingProvider } from './Provider'
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
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,
}
const defaultConfig: CardSortingConfig = {
cardCount: 8,
showNumbers: true,
timeLimit: null,
}
// Config validation function
function validateCardSortingConfig(
config: unknown,
): config is CardSortingConfig {
if (typeof config !== 'object' || config === null) {
return false
}
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 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
}
}
return true
}
export const cardSortingGame = defineGame<
CardSortingConfig,
CardSortingState,
CardSortingMove
>({
manifest,
Provider: CardSortingProvider,
GameComponent,
validator: cardSortingValidator,
defaultConfig,
validateConfig: validateCardSortingConfig,
})

View File

@@ -0,0 +1,198 @@
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
// ============================================================================
// Player Metadata
// ============================================================================
export interface PlayerMetadata {
id: string // Player ID (UUID)
name: string
emoji: string
userId: string
}
// ============================================================================
// Configuration
// ============================================================================
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
}
// ============================================================================
// Core Data Types
// ============================================================================
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
}
export interface PlacedCard {
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
}
// ============================================================================
// Game State
// ============================================================================
export interface CardSortingState extends GameState {
// Configuration
cardCount: 5 | 8 | 12 | 15
showNumbers: boolean
timeLimit: number | null
// Game phase
gamePhase: GamePhase
// 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)
// 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
// Pause/Resume (standard pattern)
originalConfig?: CardSortingConfig
pausedGamePhase?: GamePhase
pausedGameState?: {
selectedCards: SortingCard[]
availableCards: SortingCard[]
placedCards: (SortingCard | null)[]
gameStartTime: number
numbersRevealed: boolean
}
}
// ============================================================================
// Game Moves
// ============================================================================
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>
}
// ============================================================================
// Component Props
// ============================================================================
export interface SortingCardProps {
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
}
export interface ScoreDisplayProps {
breakdown: ScoreBreakdown
correctOrder: SortingCard[]
userOrder: SortingCard[]
onNewGame: () => void
onExit: () => void
}

View File

@@ -0,0 +1,51 @@
import { AbacusReact } from '@soroban/abacus-react'
import { renderToString } from 'react-dom/server'
import type { SortingCard } from '../types'
/**
* Generate random cards for sorting game
* @param count Number of cards to generate
* @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)
}
// 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} />,
)
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
}

View File

@@ -0,0 +1,109 @@
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))
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]
}
/**
* 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
}
// 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++
}
}
}
return inversions
}
/**
* Calculate comprehensive score breakdown
*/
export function calculateScore(
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
// 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,
)
// 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,
}
}

View File

@@ -0,0 +1,82 @@
import type { SortingCard } from '../types'
/**
* Place a card at a specific position, shifting existing cards
* Returns new placedCards array with no gaps
*/
export function placeCardAtPosition(
placedCards: (SortingCard | null)[],
cardToPlace: SortingCard,
position: number,
totalSlots: number,
): { placedCards: (SortingCard | null)[]; excessCards: SortingCard[] } {
// 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]
}
}
}
}
// 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)
}
}
// 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)
return { placedCards: result, excessCards: excess }
}
/**
* Remove card at position
*/
export function removeCardAtPosition(
placedCards: (SortingCard | null)[],
position: number,
): { placedCards: (SortingCard | null)[]; removedCard: SortingCard | null } {
const removedCard = placedCards[position]
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)
}
}
// 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 }
}

View File

@@ -14,8 +14,7 @@ import {
DEFAULT_MATCHING_CONFIG,
DEFAULT_MEMORY_QUIZ_CONFIG,
DEFAULT_COMPLEMENT_RACE_CONFIG,
DEFAULT_NUMBER_GUESSER_CONFIG,
DEFAULT_MATH_SPRINT_CONFIG,
DEFAULT_CARD_SORTING_CONFIG,
} from './game-configs'
// Lazy-load game registry to avoid loading React components on server
@@ -51,10 +50,8 @@ function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[Exte
return DEFAULT_MEMORY_QUIZ_CONFIG
case 'complement-race':
return DEFAULT_COMPLEMENT_RACE_CONFIG
case 'number-guesser':
return DEFAULT_NUMBER_GUESSER_CONFIG
case 'math-sprint':
return DEFAULT_MATH_SPRINT_CONFIG
case 'card-sorting':
return DEFAULT_CARD_SORTING_CONFIG
default:
throw new Error(`Unknown game: ${gameName}`)
}

View File

@@ -15,6 +15,7 @@
// Type-only imports (won't load React components at runtime)
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
import type { matchingGame } from '@/arcade-games/matching'
import type { cardSortingGame } from '@/arcade-games/card-sorting'
/**
* Utility type: Extract config type from a game definition
@@ -38,6 +39,12 @@ export type MemoryQuizGameConfig = InferGameConfig<typeof memoryQuizGame>
*/
export type MatchingGameConfig = InferGameConfig<typeof matchingGame>
/**
* Configuration for card-sorting (pattern recognition) game
* INFERRED from cardSortingGame.defaultConfig
*/
export type CardSortingGameConfig = InferGameConfig<typeof cardSortingGame>
// ============================================================================
// Legacy Games (Manual Type Definitions)
// TODO: Migrate these games to the modular system for type inference
@@ -96,6 +103,7 @@ export type GameConfigByName = {
// Modern games (inferred types)
'memory-quiz': MemoryQuizGameConfig
matching: MatchingGameConfig
'card-sorting': CardSortingGameConfig
// Legacy games (manual types)
'complement-race': ComplementRaceGameConfig
@@ -127,6 +135,12 @@ export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
playMode: 'cooperative',
}
export const DEFAULT_CARD_SORTING_CONFIG: CardSortingGameConfig = {
cardCount: 8,
showNumbers: true,
timeLimit: null,
}
export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
// Game style
style: 'practice',

View File

@@ -109,7 +109,9 @@ export function clearRegistry(): void {
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
import { matchingGame } from '@/arcade-games/matching'
import { complementRaceGame } from '@/arcade-games/complement-race/index'
import { cardSortingGame } from '@/arcade-games/card-sorting'
registerGame(memoryQuizGame)
registerGame(matchingGame)
registerGame(complementRaceGame)
registerGame(cardSortingGame)

View File

@@ -12,7 +12,7 @@ export {
validatorRegistry,
matchingGameValidator,
memoryQuizGameValidator,
numberGuesserValidator,
cardSortingValidator,
} from '../validators'
export type { GameName } from '../validators'

View File

@@ -40,8 +40,7 @@ export interface GameMove {
*/
export type { MatchingMove } from '@/arcade-games/matching/types'
export type { MemoryQuizMove } from '@/arcade-games/memory-quiz/types'
export type { NumberGuesserMove } from '@/arcade-games/number-guesser/types'
export type { MathSprintMove } from '@/arcade-games/math-sprint/types'
export type { CardSortingMove } from '@/arcade-games/card-sorting/types'
export type { ComplementRaceMove } from '@/arcade-games/complement-race/types'
/**
@@ -49,8 +48,7 @@ export type { ComplementRaceMove } from '@/arcade-games/complement-race/types'
*/
export type { MatchingState } from '@/arcade-games/matching/types'
export type { MemoryQuizState } from '@/arcade-games/memory-quiz/types'
export type { NumberGuesserState } from '@/arcade-games/number-guesser/types'
export type { MathSprintState } from '@/arcade-games/math-sprint/types'
export type { CardSortingState } from '@/arcade-games/card-sorting/types'
export type { ComplementRaceState } from '@/arcade-games/complement-race/types'
// Generic game state union (for backwards compatibility)

View File

@@ -13,6 +13,7 @@
import { matchingGameValidator } from '@/arcade-games/matching/Validator'
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
import { cardSortingValidator } from '@/arcade-games/card-sorting/Validator'
import type { GameValidator } from './validation/types'
/**
@@ -24,6 +25,7 @@ export const validatorRegistry = {
matching: matchingGameValidator,
'memory-quiz': memoryQuizGameValidator,
'complement-race': complementRaceValidator,
'card-sorting': cardSortingValidator,
// Add new games here - GameName type will auto-update
} as const
@@ -90,4 +92,9 @@ export function assertValidGameName(gameName: unknown): asserts gameName is Game
/**
* Re-export validators for backwards compatibility
*/
export { matchingGameValidator, memoryQuizGameValidator, complementRaceValidator }
export {
matchingGameValidator,
memoryQuizGameValidator,
complementRaceValidator,
cardSortingValidator,
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.7.1",
"version": "4.9.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [