Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93a1b3074 | ||
|
|
7f6fea91f6 | ||
|
|
aaa253bde0 | ||
|
|
df37260e26 | ||
|
|
b77ff78cfc | ||
|
|
9fb9786e54 | ||
|
|
72bb2eb58b | ||
|
|
55010d2bcd | ||
|
|
f735e5d3ba | ||
|
|
6e436db5e7 | ||
|
|
2953ef8917 | ||
|
|
7675e59868 | ||
|
|
357aa30618 | ||
|
|
22426f677f | ||
|
|
cf997b9cbc | ||
|
|
07d5607218 | ||
|
|
614a081ca6 | ||
|
|
71cdc342c9 | ||
|
|
214b9077ab | ||
|
|
76eb0517c2 | ||
|
|
820000f93b | ||
|
|
fa6b3b69d5 | ||
|
|
ca4ba6e2d7 | ||
|
|
ebfff1a62f | ||
|
|
ba04d7f491 | ||
|
|
054f0c0d23 |
90
CHANGELOG.md
90
CHANGELOG.md
@@ -1,3 +1,93 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** remove number-guesser and math-sprint games ([9fb9786](https://github.com/antialias/soroban-abacus-flashcards/commit/9fb9786e54928e81ecf226b36d343a73143fb674))
|
||||
|
||||
## [4.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.10...v4.7.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** enable adaptive AI difficulty in arcade ([55010d2](https://github.com/antialias/soroban-abacus-flashcards/commit/55010d2bcd953718d8fea428b1f7f613a193779c))
|
||||
|
||||
## [4.6.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.9...v4.6.10) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** improve AI speech bubble positioning ([6e436db](https://github.com/antialias/soroban-abacus-flashcards/commit/6e436db5e709d944ebffed6936ea1f8e4bd2e19e))
|
||||
* **docker:** remove reference to deleted @soroban/client package ([2953ef8](https://github.com/antialias/soroban-abacus-flashcards/commit/2953ef8917f7b13f6eb562eb7d58d14179a718da))
|
||||
|
||||
## [4.6.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.8...v4.6.9) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add missing AI commentary cooldown updates ([357aa30](https://github.com/antialias/soroban-abacus-flashcards/commit/357aa30618f80d659ae515f94b7b9254bb458910))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove dead Python bridge and unused packages ([22426f6](https://github.com/antialias/soroban-abacus-flashcards/commit/22426f677f9b127441377b95571f0066a0990d3f))
|
||||
|
||||
## [4.6.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.7...v4.6.8) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** counter-flip AI speech bubbles to make text readable ([07d5607](https://github.com/antialias/soroban-abacus-flashcards/commit/07d5607218aee03e813eceff5d161a7838d66bcb))
|
||||
|
||||
## [4.6.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.6...v4.6.7) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use active local players pattern from navbar ([71cdc34](https://github.com/antialias/soroban-abacus-flashcards/commit/71cdc342c97ca53b5e7e4202d4d344199e8ddd98))
|
||||
|
||||
## [4.6.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.5...v4.6.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use local player emoji instead of first active player ([76eb051](https://github.com/antialias/soroban-abacus-flashcards/commit/76eb0517c202d1b9160b49dec0b99ff4972daff2))
|
||||
|
||||
## [4.6.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.4...v4.6.5) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** flip player avatar to face right in practice mode ([fa6b3b6](https://github.com/antialias/soroban-abacus-flashcards/commit/fa6b3b69d5a4a7eb70f8c18fc8c122c54c4d504a))
|
||||
|
||||
## [4.6.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.3...v4.6.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** flip AI racers to face right in practice mode ([ebfff1a](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfff1a62fd104d531a8158345c8c012ec8a55d3))
|
||||
|
||||
## [4.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.2...v4.6.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** balance AI speeds to match original implementation ([054f0c0](https://github.com/antialias/soroban-abacus-flashcards/commit/054f0c0d235dc2b0042a0f6af48840d23a4c5ff8))
|
||||
|
||||
## [4.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.1...v4.6.2) (2025-10-18)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/core/client/node/package.json ./packages/core/client/node/
|
||||
COPY packages/core/client/typescript/package.json ./packages/core/client/typescript/
|
||||
COPY packages/abacus-react/package.json ./packages/abacus-react/
|
||||
COPY packages/templates/package.json ./packages/templates/
|
||||
|
||||
|
||||
@@ -92,7 +92,12 @@
|
||||
"Bash(ls:*)",
|
||||
"Bash(do if [ -f \"$file\" ])",
|
||||
"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(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(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": []
|
||||
|
||||
1761
apps/web/CARD_SORTING_PORT_PLAN.md
Normal file
1761
apps/web/CARD_SORTING_PORT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,6 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-spring/web": "^10.0.2",
|
||||
"@soroban/abacus-react": "workspace:*",
|
||||
"@soroban/client": "workspace:*",
|
||||
"@soroban/core": "workspace:*",
|
||||
"@soroban/templates": "workspace:*",
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
|
||||
@@ -17,15 +17,16 @@ interface CircularTrackProps {
|
||||
|
||||
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { players, activePlayers } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the current user's active local players (consistent with navbar pattern)
|
||||
const activeLocalPlayers = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
|
||||
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
|
||||
|
||||
// Update dimensions on mount and resize
|
||||
@@ -400,7 +401,11 @@ export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: C
|
||||
{activeBubble && (
|
||||
<div
|
||||
style={{
|
||||
transform: `rotate(${-aiPos.angle}deg)`, // Counter-rotate bubble
|
||||
position: 'absolute',
|
||||
bottom: '100%', // Position above the AI racer
|
||||
left: '50%',
|
||||
transform: `translate(-50%, -15px) rotate(${-aiPos.angle}deg)`, // Offset 15px above, counter-rotate bubble
|
||||
zIndex: 20, // Above player (10) and AI racers (5)
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
|
||||
@@ -20,13 +20,14 @@ export function LinearTrack({
|
||||
showFinishLine = true,
|
||||
}: LinearTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { players, activePlayers } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the current user's active local players (consistent with navbar pattern)
|
||||
const activeLocalPlayers = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
|
||||
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
|
||||
|
||||
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
// 2% minimum (start), 98% maximum (near finish), 96% range for race
|
||||
@@ -110,7 +111,7 @@ export function LinearTrack({
|
||||
position: 'absolute',
|
||||
left: `${playerPosition}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transform: 'translate(-50%, -50%) scaleX(-1)',
|
||||
fontSize: '32px',
|
||||
transition: 'left 0.3s ease-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
@@ -132,7 +133,7 @@ export function LinearTrack({
|
||||
position: 'absolute',
|
||||
left: `${aiPosition}%`,
|
||||
top: `${35 + index * 15}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transform: 'translate(-50%, -50%) scaleX(-1)',
|
||||
fontSize: '28px',
|
||||
transition: 'left 0.2s linear',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
@@ -141,10 +142,20 @@ export function LinearTrack({
|
||||
>
|
||||
{racer.icon}
|
||||
{activeBubble && (
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%', // Position above the AI racer
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -15px) scaleX(-1)', // Offset 15px above, counter-flip bubble
|
||||
zIndex: 20, // Above player (10) and AI racers (5)
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
525
apps/web/src/arcade-games/card-sorting/Provider.tsx
Normal file
525
apps/web/src/arcade-games/card-sorting/Provider.tsx
Normal 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
|
||||
}
|
||||
425
apps/web/src/arcade-games/card-sorting/Validator.ts
Normal file
425
apps/web/src/arcade-games/card-sorting/Validator.ts
Normal 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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
83
apps/web/src/arcade-games/card-sorting/index.ts
Normal file
83
apps/web/src/arcade-games/card-sorting/index.ts
Normal 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,
|
||||
})
|
||||
198
apps/web/src/arcade-games/card-sorting/types.ts
Normal file
198
apps/web/src/arcade-games/card-sorting/types.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
109
apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts
Normal file
109
apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
82
apps/web/src/arcade-games/card-sorting/utils/validation.ts
Normal file
82
apps/web/src/arcade-games/card-sorting/utils/validation.ts
Normal 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 }
|
||||
}
|
||||
@@ -400,7 +400,12 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
// Race mechanics
|
||||
raceGoal: multiplayerState.config.raceGoal,
|
||||
timeLimit: multiplayerState.config.timeLimit ?? null,
|
||||
speedMultiplier: 1.0,
|
||||
speedMultiplier:
|
||||
multiplayerState.config.style === 'practice'
|
||||
? 0.7
|
||||
: multiplayerState.config.style === 'sprint'
|
||||
? 0.9
|
||||
: 1.0, // Base speed multipliers by mode
|
||||
aiRacers: clientAIRacers, // Use client-side AI state
|
||||
|
||||
// Sprint mode specific (all client-side for smooth movement)
|
||||
@@ -460,17 +465,19 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
if (compatibleState.isGameActive && multiplayerState.config.enableAI) {
|
||||
const count = multiplayerState.config.aiOpponentCount
|
||||
if (count > 0 && clientAIRacers.length === 0) {
|
||||
const aiNames = ['Robo-Racer', 'Calculator', 'Speed Demon', 'Brain Bot']
|
||||
const aiNames = ['Swift AI', 'Math Bot', 'Speed Demon', 'Brain Bot']
|
||||
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
|
||||
|
||||
const newAI = []
|
||||
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
|
||||
// Use original balanced speeds: 0.32 for Swift AI, 0.2 for Math Bot
|
||||
const baseSpeed = i === 0 ? 0.32 : 0.2
|
||||
newAI.push({
|
||||
id: `ai-${i}`,
|
||||
name: aiNames[i],
|
||||
personality: personalities[i % personalities.length] as 'competitive' | 'analytical',
|
||||
position: 0,
|
||||
speed: 0.8 + Math.random() * 0.4, // Speed multiplier 0.8-1.2
|
||||
speed: baseSpeed, // Balanced speed from original single-player version
|
||||
icon: personalities[i % personalities.length] === 'competitive' ? '🏃♂️' : '🏃',
|
||||
lastComment: 0,
|
||||
commentCooldown: 0,
|
||||
@@ -793,6 +800,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
newBubbles.set(action.racerId, action.message)
|
||||
return { ...prev, activeSpeechBubbles: newBubbles }
|
||||
})
|
||||
// Update racer's lastComment time and cooldown to prevent spam
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) =>
|
||||
racer.id === action.racerId
|
||||
? {
|
||||
...racer,
|
||||
lastComment: Date.now(),
|
||||
commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds
|
||||
}
|
||||
: racer
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'CLEAR_AI_COMMENT': {
|
||||
@@ -823,12 +842,34 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'UPDATE_AI_SPEEDS': {
|
||||
// Update client-side AI speeds (adaptive difficulty)
|
||||
if (action.racers && Array.isArray(action.racers)) {
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) => {
|
||||
const update = action.racers.find(
|
||||
(r: { id: string; speed: number }) => r.id === racer.id
|
||||
)
|
||||
return update
|
||||
? {
|
||||
...racer,
|
||||
speed: update.speed,
|
||||
}
|
||||
: racer
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'UPDATE_DIFFICULTY_TRACKER': {
|
||||
// Update local difficulty tracker state
|
||||
setLocalUIState((prev) => ({ ...prev, difficultyTracker: action.tracker }))
|
||||
break
|
||||
}
|
||||
// Other local actions that don't affect UI (can be ignored for now)
|
||||
case 'UPDATE_MOMENTUM':
|
||||
case 'UPDATE_TRAIN_POSITION':
|
||||
case 'UPDATE_STEAM_JOURNEY':
|
||||
case 'UPDATE_DIFFICULTY_TRACKER':
|
||||
case 'UPDATE_AI_SPEEDS':
|
||||
case 'GENERATE_PASSENGERS': // Passengers generated server-side when route starts
|
||||
case 'COMPLETE_ROUTE':
|
||||
case 'HIDE_ROUTE_CELEBRATION':
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Provider
|
||||
*
|
||||
* Context provider for Math Sprint game state management.
|
||||
* Demonstrates free-for-all gameplay with TEAM_MOVE pattern.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
|
||||
import type { Difficulty, MathSprintState } from './types'
|
||||
|
||||
/**
|
||||
* Context value provided to child components
|
||||
*/
|
||||
interface MathSprintContextValue {
|
||||
state: MathSprintState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
submitAnswer: (answer: number) => void
|
||||
nextQuestion: () => void
|
||||
resetGame: () => void
|
||||
setConfig: (field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const MathSprintContext = createContext<MathSprintContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Math Sprint context
|
||||
*/
|
||||
export function useMathSprint() {
|
||||
const context = useContext(MathSprintContext)
|
||||
if (!context) {
|
||||
throw new Error('useMathSprint must be used within MathSprintProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Math Sprint Provider Component
|
||||
*/
|
||||
export function MathSprintProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array (keep Set iteration order)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room with defaults
|
||||
const gameConfig = useMemo(() => {
|
||||
const allGameConfigs = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = allGameConfigs?.['math-sprint'] as Record<string, unknown> | undefined
|
||||
return {
|
||||
difficulty: (savedConfig?.difficulty as Difficulty) || 'medium',
|
||||
questionsPerRound: (savedConfig?.questionsPerRound as number) || 10,
|
||||
timePerQuestion: (savedConfig?.timePerQuestion as number) || 30,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Initial state with merged config
|
||||
const initialState = useMemo<MathSprintState>(
|
||||
() => ({
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
difficulty: gameConfig.difficulty,
|
||||
questionsPerRound: gameConfig.questionsPerRound,
|
||||
timePerQuestion: gameConfig.timePerQuestion,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [],
|
||||
scores: {},
|
||||
correctAnswersCount: {},
|
||||
answers: [],
|
||||
questionStartTime: 0,
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}),
|
||||
[gameConfig]
|
||||
)
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession<MathSprintState>(
|
||||
{
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: (state) => state, // Server handles all state updates
|
||||
}
|
||||
)
|
||||
|
||||
// Action: Start game
|
||||
const startGame = useCallback(() => {
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: TEAM_MOVE, // Free-for-all: no specific turn owner
|
||||
userId: viewerId || '',
|
||||
data: { activePlayers, playerMetadata },
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
// Action: Submit answer
|
||||
const submitAnswer = useCallback(
|
||||
(answer: number) => {
|
||||
// Find this user's player ID from game state
|
||||
const myPlayerId = state.activePlayers.find((pid) => {
|
||||
return state.playerMetadata[pid]?.userId === viewerId
|
||||
})
|
||||
|
||||
if (!myPlayerId) {
|
||||
console.error('[MathSprint] No player found for current user')
|
||||
return
|
||||
}
|
||||
|
||||
sendMove({
|
||||
type: 'SUBMIT_ANSWER',
|
||||
playerId: myPlayerId, // Specific player answering
|
||||
userId: viewerId || '',
|
||||
data: { answer },
|
||||
})
|
||||
},
|
||||
[state.activePlayers, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
// Action: Next question
|
||||
const nextQuestion = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_QUESTION',
|
||||
playerId: TEAM_MOVE, // Any player can advance
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
// Action: Reset game
|
||||
const resetGame = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'RESET_GAME',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
// Action: Set config
|
||||
const setConfig = useCallback(
|
||||
(field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database for next session
|
||||
if (roomData?.id) {
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...roomData.gameConfig,
|
||||
'math-sprint': {
|
||||
...(roomData.gameConfig?.['math-sprint'] || {}),
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove, updateGameConfig, roomData]
|
||||
)
|
||||
|
||||
const contextValue: MathSprintContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
submitAnswer,
|
||||
nextQuestion,
|
||||
resetGame,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return <MathSprintContext.Provider value={contextValue}>{children}</MathSprintContext.Provider>
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Validator
|
||||
*
|
||||
* Server-side validation for Math Sprint game.
|
||||
* Generates questions, validates answers, awards points.
|
||||
*/
|
||||
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type {
|
||||
Difficulty,
|
||||
MathSprintConfig,
|
||||
MathSprintMove,
|
||||
MathSprintState,
|
||||
Operation,
|
||||
Question,
|
||||
} from './types'
|
||||
|
||||
export class MathSprintValidator implements GameValidator<MathSprintState, MathSprintMove> {
|
||||
/**
|
||||
* Validate a game move
|
||||
*/
|
||||
validateMove(
|
||||
state: MathSprintState,
|
||||
move: MathSprintMove,
|
||||
context?: { userId?: string }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'SUBMIT_ANSWER':
|
||||
return this.validateSubmitAnswer(
|
||||
state,
|
||||
move.playerId,
|
||||
Number(move.data.answer),
|
||||
move.timestamp
|
||||
)
|
||||
|
||||
case 'NEXT_QUESTION':
|
||||
return this.validateNextQuestion(state)
|
||||
|
||||
case 'RESET_GAME':
|
||||
return this.validateResetGame(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game is complete
|
||||
*/
|
||||
isGameComplete(state: MathSprintState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initial state for new game
|
||||
*/
|
||||
getInitialState(config: unknown): MathSprintState {
|
||||
const { difficulty, questionsPerRound, timePerQuestion } = config as MathSprintConfig
|
||||
|
||||
return {
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
difficulty: difficulty || 'medium',
|
||||
questionsPerRound: questionsPerRound || 10,
|
||||
timePerQuestion: timePerQuestion || 30,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [],
|
||||
scores: {},
|
||||
correctAnswersCount: {},
|
||||
answers: [],
|
||||
questionStartTime: 0,
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Methods
|
||||
// ============================================================================
|
||||
|
||||
private validateStartGame(
|
||||
state: MathSprintState,
|
||||
activePlayers: string[],
|
||||
playerMetadata: Record<string, any>
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Game already started' }
|
||||
}
|
||||
|
||||
if (activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
// Generate questions
|
||||
const questions = this.generateQuestions(state.difficulty, state.questionsPerRound)
|
||||
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
questions,
|
||||
currentQuestionIndex: 0,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
correctAnswersCount: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
answers: [],
|
||||
questionStartTime: Date.now(),
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSubmitAnswer(
|
||||
state: MathSprintState,
|
||||
playerId: string,
|
||||
answer: number,
|
||||
timestamp: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in progress' }
|
||||
}
|
||||
|
||||
if (!state.activePlayers.includes(playerId)) {
|
||||
return { valid: false, error: 'Player not in game' }
|
||||
}
|
||||
|
||||
if (state.questionAnswered) {
|
||||
return { valid: false, error: 'Question already answered correctly' }
|
||||
}
|
||||
|
||||
// Check if player already answered this question
|
||||
const alreadyAnswered = state.answers.some((a) => a.playerId === playerId)
|
||||
if (alreadyAnswered) {
|
||||
return { valid: false, error: 'You already answered this question' }
|
||||
}
|
||||
|
||||
const currentQuestion = state.questions[state.currentQuestionIndex]
|
||||
const correct = answer === currentQuestion.correctAnswer
|
||||
|
||||
const answerRecord = {
|
||||
playerId,
|
||||
answer,
|
||||
timestamp,
|
||||
correct,
|
||||
}
|
||||
|
||||
const newAnswers = [...state.answers, answerRecord]
|
||||
let newState = { ...state, answers: newAnswers }
|
||||
|
||||
// If correct, award points and mark question as answered
|
||||
if (correct) {
|
||||
newState = {
|
||||
...newState,
|
||||
questionAnswered: true,
|
||||
winnerId: playerId,
|
||||
scores: {
|
||||
...state.scores,
|
||||
[playerId]: state.scores[playerId] + 10,
|
||||
},
|
||||
correctAnswersCount: {
|
||||
...state.correctAnswersCount,
|
||||
[playerId]: state.correctAnswersCount[playerId] + 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateNextQuestion(state: MathSprintState): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in progress' }
|
||||
}
|
||||
|
||||
if (!state.questionAnswered) {
|
||||
return { valid: false, error: 'Current question not answered yet' }
|
||||
}
|
||||
|
||||
const isLastQuestion = state.currentQuestionIndex >= state.questions.length - 1
|
||||
|
||||
if (isLastQuestion) {
|
||||
// Game complete, go to results
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
}
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Move to next question
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
currentQuestionIndex: state.currentQuestionIndex + 1,
|
||||
answers: [],
|
||||
questionStartTime: Date.now(),
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateResetGame(state: MathSprintState): ValidationResult {
|
||||
const newState = this.getInitialState({
|
||||
difficulty: state.difficulty,
|
||||
questionsPerRound: state.questionsPerRound,
|
||||
timePerQuestion: state.timePerQuestion,
|
||||
})
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetConfig(state: MathSprintState, field: string, value: any): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Cannot change config during game' }
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Question Generation
|
||||
// ============================================================================
|
||||
|
||||
private generateQuestions(difficulty: Difficulty, count: number): Question[] {
|
||||
const questions: Question[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const operation = this.randomOperation()
|
||||
const question = this.generateQuestion(difficulty, operation, `q-${i}`)
|
||||
questions.push(question)
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
private generateQuestion(difficulty: Difficulty, operation: Operation, id: string): Question {
|
||||
let operand1: number
|
||||
let operand2: number
|
||||
let correctAnswer: number
|
||||
|
||||
switch (difficulty) {
|
||||
case 'easy':
|
||||
operand1 = this.randomInt(1, 10)
|
||||
operand2 = this.randomInt(1, 10)
|
||||
break
|
||||
case 'medium':
|
||||
operand1 = this.randomInt(10, 50)
|
||||
operand2 = this.randomInt(1, 20)
|
||||
break
|
||||
case 'hard':
|
||||
operand1 = this.randomInt(10, 100)
|
||||
operand2 = this.randomInt(10, 50)
|
||||
break
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'addition':
|
||||
correctAnswer = operand1 + operand2
|
||||
break
|
||||
case 'subtraction':
|
||||
// Ensure positive result
|
||||
if (operand1 < operand2) {
|
||||
;[operand1, operand2] = [operand2, operand1]
|
||||
}
|
||||
correctAnswer = operand1 - operand2
|
||||
break
|
||||
case 'multiplication':
|
||||
// Smaller numbers for multiplication
|
||||
if (difficulty === 'hard') {
|
||||
operand1 = this.randomInt(2, 20)
|
||||
operand2 = this.randomInt(2, 12)
|
||||
} else {
|
||||
operand1 = this.randomInt(2, 10)
|
||||
operand2 = this.randomInt(2, 10)
|
||||
}
|
||||
correctAnswer = operand1 * operand2
|
||||
break
|
||||
}
|
||||
|
||||
const operationSymbol = this.getOperationSymbol(operation)
|
||||
const displayText = `${operand1} ${operationSymbol} ${operand2} = ?`
|
||||
|
||||
return {
|
||||
id,
|
||||
operand1,
|
||||
operand2,
|
||||
operation,
|
||||
correctAnswer,
|
||||
displayText,
|
||||
}
|
||||
}
|
||||
|
||||
private randomOperation(): Operation {
|
||||
const operations: Operation[] = ['addition', 'subtraction', 'multiplication']
|
||||
return operations[Math.floor(Math.random() * operations.length)]
|
||||
}
|
||||
|
||||
private randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
private getOperationSymbol(operation: Operation): string {
|
||||
switch (operation) {
|
||||
case 'addition':
|
||||
return '+'
|
||||
case 'subtraction':
|
||||
return '−'
|
||||
case 'multiplication':
|
||||
return '×'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mathSprintValidator = new MathSprintValidator()
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Game Component
|
||||
*
|
||||
* Main wrapper component with navigation and phase routing.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useMathSprint } from '../Provider'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useMathSprint()
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Math Sprint"
|
||||
navEmoji="🧮"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
// No currentPlayerId - free-for-all game, everyone can act simultaneously
|
||||
playerScores={state.scores}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
resetGame()
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Playing Phase
|
||||
*
|
||||
* Main gameplay: show question, accept answers, show feedback.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
|
||||
export function PlayingPhase() {
|
||||
const { state, submitAnswer, nextQuestion, lastError, clearError } = useMathSprint()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const currentQuestion = state.questions[state.currentQuestionIndex]
|
||||
const progress = `${state.currentQuestionIndex + 1} / ${state.questions.length}`
|
||||
|
||||
// Find if current user answered
|
||||
const myPlayerId = Object.keys(state.playerMetadata).find(
|
||||
(pid) => state.playerMetadata[pid]?.userId === viewerId
|
||||
)
|
||||
const myAnswer = state.answers.find((a) => a.playerId === myPlayerId)
|
||||
|
||||
// Auto-clear error after 3 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
// Clear input after question changes
|
||||
useEffect(() => {
|
||||
setInputValue('')
|
||||
}, [state.currentQuestionIndex])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const answer = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(answer)) return
|
||||
|
||||
submitAnswer(answer)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '700px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>
|
||||
Question {progress}
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
background: 'gray.200',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(90deg, #a78bfa, #8b5cf6)',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.3s',
|
||||
})}
|
||||
style={{
|
||||
width: `${((state.currentQuestionIndex + 1) / state.questions.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span>⚠️</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'red.700' })}>{lastError}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
padding: '4px 8px',
|
||||
background: 'red.100',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'red.200' },
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Display */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.300',
|
||||
borderRadius: '16px',
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{currentQuestion.displayText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer Input */}
|
||||
{!state.questionAnswered && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: myAnswer ? 'gray.300' : 'purple.500',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
{myAnswer ? (
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Your answer: <strong>{myAnswer.answer}</strong>
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
|
||||
Waiting for others or correct answer...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Your Answer
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '12px' })}>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your answer..."
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'purple.500',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: inputValue ? 'purple.600' : 'gray.400',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: inputValue ? 'pointer' : 'not-allowed',
|
||||
_hover: {
|
||||
background: inputValue ? 'purple.700' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Winner Display */}
|
||||
{state.questionAnswered && state.winnerId && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
|
||||
border: '2px solid',
|
||||
borderColor: 'green.400',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '3xl', marginBottom: '8px' })}>🎉</div>
|
||||
<div className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'green.700' })}>
|
||||
{state.playerMetadata[state.winnerId]?.name || 'Someone'} got it right!
|
||||
</div>
|
||||
<div className={css({ fontSize: 'md', color: 'green.600', marginTop: '4px' })}>
|
||||
Answer: {currentQuestion.correctAnswer}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextQuestion}
|
||||
className={css({
|
||||
marginTop: '16px',
|
||||
padding: '12px 32px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: 'green.600',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'green.700' },
|
||||
})}
|
||||
>
|
||||
Next Question →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Scores
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
|
||||
{Object.entries(state.scores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([playerId, score]) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
background: 'gray.50',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{player?.emoji}</span>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
|
||||
{player?.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}
|
||||
>
|
||||
{score} pts
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Results Phase
|
||||
*
|
||||
* Show final scores and winner.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, resetGame } = useMathSprint()
|
||||
|
||||
// Sort players by score
|
||||
const sortedPlayers = Object.entries(state.scores)
|
||||
.map(([playerId, score]) => ({
|
||||
playerId,
|
||||
score,
|
||||
correct: state.correctAnswersCount[playerId] || 0,
|
||||
player: state.playerMetadata[playerId],
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
const winner = sortedPlayers[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Winner Announcement */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
border: '2px solid',
|
||||
borderColor: 'yellow.400',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '4xl', marginBottom: '12px' })}>🏆</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'yellow.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{winner.player?.name} Wins!
|
||||
</h2>
|
||||
<div className={css({ fontSize: 'lg', color: 'yellow.700' })}>
|
||||
{winner.score} points • {winner.correct} correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Scores */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Final Scores
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{sortedPlayers.map((item, index) => (
|
||||
<div
|
||||
key={item.playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
background: index === 0 ? 'linear-gradient(135deg, #fef3c7, #fde68a)' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: index === 0 ? 'yellow.300' : 'gray.200',
|
||||
borderRadius: '12px',
|
||||
})}
|
||||
>
|
||||
{/* Rank */}
|
||||
<div
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: index === 0 ? 'yellow.500' : 'gray.300',
|
||||
color: index === 0 ? 'white' : 'gray.700',
|
||||
borderRadius: '50%',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Player Info */}
|
||||
<div className={css({ flex: 1 })}>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{item.player?.emoji}</span>
|
||||
<span className={css({ fontSize: 'md', fontWeight: 'semibold' })}>
|
||||
{item.player?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.600', marginTop: '2px' })}>
|
||||
{item.correct} / {state.questions.length} correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: index === 0 ? 'yellow.700' : 'purple.600',
|
||||
})}
|
||||
>
|
||||
{item.score}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
|
||||
border: '1px solid',
|
||||
borderColor: 'purple.300',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
|
||||
{state.questions.length}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Questions</div>
|
||||
</div>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
|
||||
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Difficulty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play Again Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetGame}
|
||||
className={css({
|
||||
padding: '14px 28px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: 'purple.600',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
_hover: {
|
||||
background: 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Setup Phase
|
||||
*
|
||||
* Configure game settings before starting.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
import type { Difficulty } from '../types'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, startGame, setConfig } = useMathSprint()
|
||||
|
||||
const handleDifficultyChange = (difficulty: Difficulty) => {
|
||||
setConfig('difficulty', difficulty)
|
||||
}
|
||||
|
||||
const handleQuestionsChange = (questions: number) => {
|
||||
setConfig('questionsPerRound', questions)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Game Title */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
🧮 Math Sprint
|
||||
</h1>
|
||||
<p className={css({ color: 'gray.600' })}>
|
||||
Race to solve math problems! First correct answer wins points.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings Card */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Game Settings
|
||||
</h2>
|
||||
|
||||
{/* Difficulty */}
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Difficulty
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '8px' })}>
|
||||
{(['easy', 'medium', 'hard'] as Difficulty[]).map((diff) => (
|
||||
<button
|
||||
key={diff}
|
||||
type="button"
|
||||
onClick={() => handleDifficultyChange(diff)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: state.difficulty === diff ? 'purple.500' : 'gray.300',
|
||||
background: state.difficulty === diff ? 'purple.50' : 'white',
|
||||
color: state.difficulty === diff ? 'purple.700' : 'gray.700',
|
||||
fontWeight: state.difficulty === diff ? 'semibold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'purple.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{diff.charAt(0).toUpperCase() + diff.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.500', marginTop: '4px' })}>
|
||||
{state.difficulty === 'easy' && 'Numbers 1-10, simple operations'}
|
||||
{state.difficulty === 'medium' && 'Numbers 1-50, varied operations'}
|
||||
{state.difficulty === 'hard' && 'Numbers 1-100, harder calculations'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Questions Per Round */}
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Questions: {state.questionsPerRound}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="20"
|
||||
step="5"
|
||||
value={state.questionsPerRound}
|
||||
onChange={(e) => handleQuestionsChange(Number(e.target.value))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}
|
||||
>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
<span>15</span>
|
||||
<span>20</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.300',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3 className={css({ fontSize: 'sm', fontWeight: 'semibold', marginBottom: '8px' })}>
|
||||
How to Play
|
||||
</h3>
|
||||
<ul className={css({ fontSize: 'sm', color: 'gray.700', paddingLeft: '20px' })}>
|
||||
<li>Solve math problems as fast as you can</li>
|
||||
<li>First correct answer earns 10 points</li>
|
||||
<li>Everyone can answer at the same time</li>
|
||||
<li>Most points wins!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Start Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
disabled={state.activePlayers.length < 2}
|
||||
className={css({
|
||||
padding: '14px 28px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.600',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: state.activePlayers.length < 2 ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.length < 2
|
||||
? `Need ${2 - state.activePlayers.length} more player(s)`
|
||||
: 'Start Game'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
name: math-sprint
|
||||
displayName: Math Sprint
|
||||
icon: 🧮
|
||||
description: Fast-paced math racing game
|
||||
longDescription: Race against other players to solve math problems! Answer questions quickly to earn points. First person to answer correctly wins the round. Features multiple difficulty levels and customizable question counts.
|
||||
maxPlayers: 6
|
||||
difficulty: Beginner
|
||||
chips:
|
||||
- 👥 Multiplayer
|
||||
- ⚡ Free-for-All
|
||||
- 🧮 Math Skills
|
||||
- 🏃 Speed
|
||||
color: purple
|
||||
gradient: linear-gradient(135deg, #ddd6fe, #c4b5fd)
|
||||
borderColor: purple.200
|
||||
available: true
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Game Definition
|
||||
*
|
||||
* A free-for-all math game demonstrating the TEAM_MOVE pattern.
|
||||
* Players race to solve math problems - first correct answer wins points.
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { MathSprintProvider } from './Provider'
|
||||
import type { MathSprintConfig, MathSprintMove, MathSprintState } from './types'
|
||||
import { mathSprintValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'math-sprint',
|
||||
displayName: 'Math Sprint',
|
||||
icon: '🧮',
|
||||
description: 'Race to solve math problems!',
|
||||
longDescription:
|
||||
'A fast-paced free-for-all game where players compete to solve math problems. First correct answer earns points. Choose your difficulty and test your mental math skills!',
|
||||
maxPlayers: 8,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '⚡ Fast-Paced', '🧠 Mental Math'],
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #ddd6fe, #c4b5fd)',
|
||||
borderColor: 'purple.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: MathSprintConfig = {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
'difficulty' in config &&
|
||||
'questionsPerRound' in config &&
|
||||
'timePerQuestion' in config &&
|
||||
['easy', 'medium', 'hard'].includes((config as any).difficulty) &&
|
||||
typeof (config as any).questionsPerRound === 'number' &&
|
||||
typeof (config as any).timePerQuestion === 'number' &&
|
||||
(config as any).questionsPerRound >= 5 &&
|
||||
(config as any).questionsPerRound <= 20 &&
|
||||
(config as any).timePerQuestion >= 10
|
||||
)
|
||||
}
|
||||
|
||||
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
|
||||
manifest,
|
||||
Provider: MathSprintProvider,
|
||||
GameComponent,
|
||||
validator: mathSprintValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateMathSprintConfig,
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Game Types
|
||||
*
|
||||
* A free-for-all game where players race to solve math problems.
|
||||
* Demonstrates the TEAM_MOVE pattern (no specific turn owner).
|
||||
*/
|
||||
|
||||
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
|
||||
|
||||
/**
|
||||
* Difficulty levels for math problems
|
||||
*/
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||
|
||||
/**
|
||||
* Math operation types
|
||||
*/
|
||||
export type Operation = 'addition' | 'subtraction' | 'multiplication'
|
||||
|
||||
/**
|
||||
* Game configuration (persisted to database)
|
||||
*/
|
||||
export interface MathSprintConfig extends GameConfig {
|
||||
difficulty: Difficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number // seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* A math question
|
||||
*/
|
||||
export interface Question {
|
||||
id: string
|
||||
operand1: number
|
||||
operand2: number
|
||||
operation: Operation
|
||||
correctAnswer: number
|
||||
displayText: string // e.g., "5 + 3 = ?"
|
||||
}
|
||||
|
||||
/**
|
||||
* Player answer submission
|
||||
*/
|
||||
export interface Answer {
|
||||
playerId: string
|
||||
answer: number
|
||||
timestamp: number
|
||||
correct: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Game state (synchronized across all clients)
|
||||
*/
|
||||
export interface MathSprintState extends GameState {
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
|
||||
|
||||
// Configuration
|
||||
difficulty: Difficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number
|
||||
|
||||
// Game progress
|
||||
currentQuestionIndex: number
|
||||
questions: Question[]
|
||||
|
||||
// Scoring
|
||||
scores: Record<string, number> // playerId -> score
|
||||
correctAnswersCount: Record<string, number> // playerId -> count
|
||||
|
||||
// Current question state
|
||||
answers: Answer[] // All answers for current question
|
||||
questionStartTime: number // Timestamp when question was shown
|
||||
questionAnswered: boolean // True if someone got it right
|
||||
winnerId: string | null // Winner of current question (first correct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move types for Math Sprint
|
||||
*/
|
||||
export type MathSprintMove =
|
||||
| StartGameMove
|
||||
| SubmitAnswerMove
|
||||
| NextQuestionMove
|
||||
| ResetGameMove
|
||||
| SetConfigMove
|
||||
|
||||
export interface StartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmitAnswerMove extends GameMove {
|
||||
type: 'SUBMIT_ANSWER'
|
||||
data: {
|
||||
answer: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextQuestionMove extends GameMove {
|
||||
type: 'NEXT_QUESTION'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface ResetGameMove extends GameMove {
|
||||
type: 'RESET_GAME'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion'
|
||||
value: Difficulty | number
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Number Guesser Provider
|
||||
* Manages game state using the Arcade SDK
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserState } from './types'
|
||||
|
||||
/**
|
||||
* Context value interface
|
||||
*/
|
||||
interface NumberGuesserContextValue {
|
||||
state: NumberGuesserState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
chooseNumber: (number: number) => void
|
||||
makeGuess: (guess: number) => void
|
||||
nextRound: () => void
|
||||
goToSetup: () => void
|
||||
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const NumberGuesserContext = createContext<NumberGuesserContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Number Guesser context
|
||||
*/
|
||||
export function useNumberGuesser() {
|
||||
const context = useContext(NumberGuesserContext)
|
||||
if (!context) {
|
||||
throw new Error('useNumberGuesser must be used within NumberGuesserProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application
|
||||
*/
|
||||
function applyMoveOptimistically(state: NumberGuesserState, move: GameMove): NumberGuesserState {
|
||||
// For simplicity, just return current state
|
||||
// Server will send back the validated new state
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Number Guesser Provider Component
|
||||
*/
|
||||
export function NumberGuesserProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array (keep Set iteration order to match UI display)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room
|
||||
const initialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = gameConfig?.['number-guesser'] as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
minNumber: (savedConfig?.minNumber as number) || 1,
|
||||
maxNumber: (savedConfig?.maxNumber as number) || 100,
|
||||
roundsToWin: (savedConfig?.roundsToWin as number) || 3,
|
||||
gamePhase: 'setup' as const,
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<NumberGuesserState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (activePlayers.length < 2) {
|
||||
console.error('Need at least 2 players to start')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: activePlayers[0],
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const chooseNumber = useCallback(
|
||||
(secretNumber: number) => {
|
||||
sendMove({
|
||||
type: 'CHOOSE_NUMBER',
|
||||
playerId: state.chooser,
|
||||
userId: viewerId || '',
|
||||
data: { secretNumber },
|
||||
})
|
||||
},
|
||||
[state.chooser, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const makeGuess = useCallback(
|
||||
(guess: number) => {
|
||||
const playerName = state.playerMetadata[state.currentGuesser]?.name || 'Unknown'
|
||||
|
||||
sendMove({
|
||||
type: 'MAKE_GUESS',
|
||||
playerId: state.currentGuesser,
|
||||
userId: viewerId || '',
|
||||
data: { guess, playerName },
|
||||
})
|
||||
},
|
||||
[state.currentGuesser, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const nextRound = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_ROUND',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, viewerId, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: activePlayers[0] || state.chooser || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.chooser, viewerId, sendMove])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
|
||||
const currentNumberGuesserConfig =
|
||||
(currentGameConfig['number-guesser'] as Record<string, unknown>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'number-guesser': {
|
||||
...currentNumberGuesserConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
}
|
||||
},
|
||||
[activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const contextValue: NumberGuesserContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
chooseNumber,
|
||||
makeGuess,
|
||||
nextRound,
|
||||
goToSetup,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* Server-side validator for Number Guesser game
|
||||
*/
|
||||
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
|
||||
|
||||
export class NumberGuesserValidator
|
||||
implements GameValidator<NumberGuesserState, NumberGuesserMove>
|
||||
{
|
||||
validateMove(state: NumberGuesserState, move: NumberGuesserMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'CHOOSE_NUMBER':
|
||||
// Ensure secretNumber is a number (JSON deserialization can make it a string)
|
||||
return this.validateChooseNumber(state, Number(move.data.secretNumber), move.playerId)
|
||||
|
||||
case 'MAKE_GUESS':
|
||||
// Ensure guess is a number (JSON deserialization can make it a string)
|
||||
return this.validateMakeGuess(
|
||||
state,
|
||||
Number(move.data.guess),
|
||||
move.playerId,
|
||||
move.data.playerName
|
||||
)
|
||||
|
||||
case 'NEXT_ROUND':
|
||||
return this.validateNextRound(state)
|
||||
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
// Ensure value is a number (JSON deserialization can make it a string)
|
||||
return this.validateSetConfig(state, move.data.field, Number(move.data.value))
|
||||
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${(move as { type: string }).type}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(
|
||||
state: NumberGuesserState,
|
||||
activePlayers: string[],
|
||||
playerMetadata: Record<string, unknown>
|
||||
): ValidationResult {
|
||||
if (!activePlayers || activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'choosing',
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata as typeof state.playerMetadata,
|
||||
chooser: activePlayers[0],
|
||||
currentGuesser: '',
|
||||
secretNumber: null,
|
||||
guesses: [],
|
||||
roundNumber: 1,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateChooseNumber(
|
||||
state: NumberGuesserState,
|
||||
secretNumber: number,
|
||||
playerId: string
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'choosing') {
|
||||
return { valid: false, error: 'Not in choosing phase' }
|
||||
}
|
||||
|
||||
if (playerId !== state.chooser) {
|
||||
return { valid: false, error: 'Not your turn to choose' }
|
||||
}
|
||||
|
||||
if (
|
||||
secretNumber < state.minNumber ||
|
||||
secretNumber > state.maxNumber ||
|
||||
!Number.isInteger(secretNumber)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Number must be between ${state.minNumber} and ${state.maxNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[NumberGuesser] Setting secret number:', {
|
||||
secretNumber,
|
||||
secretNumberType: typeof secretNumber,
|
||||
})
|
||||
|
||||
// First guesser is the next player after chooser
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const firstGuesserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
const firstGuesser = state.activePlayers[firstGuesserIndex]
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'guessing',
|
||||
secretNumber,
|
||||
currentGuesser: firstGuesser,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateMakeGuess(
|
||||
state: NumberGuesserState,
|
||||
guess: number,
|
||||
playerId: string,
|
||||
playerName: string
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing') {
|
||||
return { valid: false, error: 'Not in guessing phase' }
|
||||
}
|
||||
|
||||
if (playerId !== state.currentGuesser) {
|
||||
return { valid: false, error: 'Not your turn to guess' }
|
||||
}
|
||||
|
||||
if (guess < state.minNumber || guess > state.maxNumber || !Number.isInteger(guess)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Guess must be between ${state.minNumber} and ${state.maxNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.secretNumber) {
|
||||
return { valid: false, error: 'No secret number set' }
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[NumberGuesser] Validating guess:', {
|
||||
guess,
|
||||
guessType: typeof guess,
|
||||
secretNumber: state.secretNumber,
|
||||
secretNumberType: typeof state.secretNumber,
|
||||
})
|
||||
|
||||
const distance = Math.abs(guess - state.secretNumber)
|
||||
|
||||
console.log('[NumberGuesser] Calculated distance:', distance)
|
||||
const newGuess = {
|
||||
playerId,
|
||||
playerName,
|
||||
guess,
|
||||
distance,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const guesses = [...state.guesses, newGuess]
|
||||
|
||||
// Check if guess is correct
|
||||
if (distance === 0) {
|
||||
// Correct guess! Award point and end round
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[playerId]: (state.scores[playerId] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if player won
|
||||
const winner = newScores[playerId] >= state.roundsToWin ? playerId : null
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
guesses,
|
||||
scores: newScores,
|
||||
gamePhase: winner ? 'results' : 'guessing',
|
||||
gameEndTime: winner ? Date.now() : null,
|
||||
winner,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Incorrect guess, move to next guesser
|
||||
const guesserIndex = state.activePlayers.indexOf(state.currentGuesser)
|
||||
let nextGuesserIndex = (guesserIndex + 1) % state.activePlayers.length
|
||||
|
||||
// Skip the chooser
|
||||
if (state.activePlayers[nextGuesserIndex] === state.chooser) {
|
||||
nextGuesserIndex = (nextGuesserIndex + 1) % state.activePlayers.length
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
guesses,
|
||||
currentGuesser: state.activePlayers[nextGuesserIndex],
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateNextRound(state: NumberGuesserState): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing') {
|
||||
return { valid: false, error: 'Not in guessing phase' }
|
||||
}
|
||||
|
||||
// Check if the round is complete (someone guessed correctly)
|
||||
const roundComplete =
|
||||
state.guesses.length > 0 && state.guesses[state.guesses.length - 1].distance === 0
|
||||
|
||||
if (!roundComplete) {
|
||||
return { valid: false, error: 'Round not complete yet - no one has guessed the number' }
|
||||
}
|
||||
|
||||
// Rotate chooser to next player
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const nextChooserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
const nextChooser = state.activePlayers[nextChooserIndex]
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'choosing',
|
||||
chooser: nextChooser,
|
||||
currentGuesser: '',
|
||||
secretNumber: null,
|
||||
guesses: [],
|
||||
roundNumber: state.roundNumber + 1,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: NumberGuesserState): ValidationResult {
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: NumberGuesserState,
|
||||
field: 'minNumber' | 'maxNumber' | 'roundsToWin',
|
||||
value: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change config in setup' }
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
return { valid: false, error: 'Value must be a positive integer' }
|
||||
}
|
||||
|
||||
if (field === 'minNumber' && value >= state.maxNumber) {
|
||||
return { valid: false, error: 'Min must be less than max' }
|
||||
}
|
||||
|
||||
if (field === 'maxNumber' && value <= state.minNumber) {
|
||||
return { valid: false, error: 'Max must be greater than min' }
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
isGameComplete(state: NumberGuesserState): boolean {
|
||||
return state.gamePhase === 'results' && state.winner !== null
|
||||
}
|
||||
|
||||
getInitialState(config: unknown): NumberGuesserState {
|
||||
const { minNumber, maxNumber, roundsToWin } = config as NumberGuesserConfig
|
||||
|
||||
return {
|
||||
minNumber: minNumber || 1,
|
||||
maxNumber: maxNumber || 100,
|
||||
roundsToWin: roundsToWin || 3,
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const numberGuesserValidator = new NumberGuesserValidator()
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Choosing Phase - Chooser picks a secret number
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function ChoosingPhase() {
|
||||
const { state, chooseNumber } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const chooserMetadata = state.playerMetadata[state.chooser]
|
||||
const isChooser = chooserMetadata?.userId === viewerId
|
||||
|
||||
const handleSubmit = () => {
|
||||
const number = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(number)) return
|
||||
|
||||
chooseNumber(number)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{chooserMetadata?.emoji || '🤔'}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{isChooser ? "You're choosing!" : `${chooserMetadata?.name || 'Someone'} is choosing...`}
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Round {state.roundNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isChooser ? (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a secret number ({state.minNumber} - {state.maxNumber})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
min={state.minNumber}
|
||||
max={state.maxNumber}
|
||||
placeholder={`${state.minNumber} - ${state.maxNumber}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'xl',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Confirm Choice
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Waiting for {chooserMetadata?.name || 'player'} to choose a number...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '32px',
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Scores
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.map((playerId) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
background: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{player?.emoji} {player?.name}: {state.scores[playerId] || 0}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Number Guesser Game Component
|
||||
* Main component that switches between game phases
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
import { ChoosingPhase } from './ChoosingPhase'
|
||||
import { GuessingPhase } from './GuessingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, goToSetup } = useNumberGuesser()
|
||||
|
||||
// Determine whose turn it is based on game phase
|
||||
const currentPlayerId =
|
||||
state.gamePhase === 'choosing'
|
||||
? state.chooser
|
||||
: state.gamePhase === 'guessing'
|
||||
? state.currentGuesser
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Number Guesser"
|
||||
navEmoji="🎯"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={state.scores}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
goToSetup?.()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #fff7ed, #ffedd5)',
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'choosing' && <ChoosingPhase />}
|
||||
{state.gamePhase === 'guessing' && <GuessingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
/**
|
||||
* Guessing Phase - Players take turns guessing the secret number
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function GuessingPhase() {
|
||||
const { state, makeGuess, nextRound, lastError, clearError } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const currentGuesserMetadata = state.playerMetadata[state.currentGuesser]
|
||||
const isCurrentGuesser = currentGuesserMetadata?.userId === viewerId
|
||||
|
||||
// Check if someone just won the round
|
||||
const lastGuess = state.guesses[state.guesses.length - 1]
|
||||
const roundJustEnded = lastGuess?.distance === 0
|
||||
|
||||
// Auto-clear error after 5 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const guess = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(guess)) return
|
||||
|
||||
makeGuess(guess)
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
const getHotColdMessage = (distance: number) => {
|
||||
if (distance === 0) return '🎯 Correct!'
|
||||
if (distance <= 5) return '🔥 Very Hot!'
|
||||
if (distance <= 10) return '🌡️ Hot'
|
||||
if (distance <= 20) return '😊 Warm'
|
||||
if (distance <= 30) return '😐 Cool'
|
||||
if (distance <= 50) return '❄️ Cold'
|
||||
return '🧊 Very Cold'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{roundJustEnded ? '🎉' : currentGuesserMetadata?.emoji || '🤔'}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{roundJustEnded
|
||||
? `${lastGuess.playerName} guessed it!`
|
||||
: isCurrentGuesser
|
||||
? 'Your turn to guess!'
|
||||
: `${currentGuesserMetadata?.name || 'Someone'} is guessing...`}
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Round {state.roundNumber} • Range: {state.minNumber} - {state.maxNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
animation: 'slideIn 0.3s ease',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
})}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Move Rejected
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'red.600',
|
||||
})}
|
||||
>
|
||||
{lastError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
color: 'red.700',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'red.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Round ended - show next round button */}
|
||||
{roundJustEnded && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'green.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
🎯
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
The secret number was <strong>{state.secretNumber}</strong>!
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextRound}
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Next Round
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guessing input (only if round not ended) */}
|
||||
{!roundJustEnded && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
{isCurrentGuesser ? (
|
||||
<>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Make your guess ({state.minNumber} - {state.maxNumber})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && inputValue) {
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
min={state.minNumber}
|
||||
max={state.maxNumber}
|
||||
placeholder={`${state.minNumber} - ${state.maxNumber}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'xl',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Submit Guess
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Waiting for {currentGuesserMetadata?.name || 'player'} to guess...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guess history */}
|
||||
{state.guesses.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Guess History
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
{state.guesses.map((guess, index) => {
|
||||
const player = state.playerMetadata[guess.playerId]
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={css({
|
||||
padding: '12px',
|
||||
background: guess.distance === 0 ? 'green.50' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<span>{player?.emoji || '🎮'}</span>
|
||||
<span className={css({ fontWeight: '600' })}>{guess.playerName}</span>
|
||||
<span className={css({ color: 'gray.600' })}>guessed</span>
|
||||
<span className={css({ fontWeight: 'bold', fontSize: 'lg' })}>
|
||||
{guess.guess}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: guess.distance === 0 ? 'green.700' : 'orange.700',
|
||||
})}
|
||||
>
|
||||
{getHotColdMessage(guess.distance)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Scores (First to {state.roundsToWin} wins!)
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.map((playerId) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
const score = state.scores[playerId] || 0
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
background: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{player?.emoji} {player?.name}: {score}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* Results Phase - Shows winner and final scores
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, goToSetup } = useNumberGuesser()
|
||||
|
||||
const winnerMetadata = state.winner ? state.playerMetadata[state.winner] : null
|
||||
const winnerScore = state.winner ? state.scores[state.winner] : 0
|
||||
|
||||
// Sort players by score
|
||||
const sortedPlayers = [...state.activePlayers].sort((a, b) => {
|
||||
const scoreA = state.scores[a] || 0
|
||||
const scoreB = state.scores[b] || 0
|
||||
return scoreB - scoreA
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Winner Celebration */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '96px',
|
||||
marginBottom: '16px',
|
||||
animation: 'bounce 1s ease-in-out infinite',
|
||||
})}
|
||||
>
|
||||
{winnerMetadata?.emoji || '🏆'}
|
||||
</div>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
{winnerMetadata?.name || 'Someone'} Wins!
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
with {winnerScore} {winnerScore === 1 ? 'round' : 'rounds'} won
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Final Standings */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Final Standings
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
{sortedPlayers.map((playerId, index) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
const score = state.scores[playerId] || 0
|
||||
const isWinner = playerId === state.winner
|
||||
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px',
|
||||
background: isWinner ? 'linear-gradient(135deg, #fed7aa, #fdba74)' : 'gray.100',
|
||||
borderRadius: '8px',
|
||||
border: isWinner ? '2px solid' : 'none',
|
||||
borderColor: isWinner ? 'orange.300' : undefined,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.400',
|
||||
width: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={css({ fontSize: '32px' })}>{player?.emoji || '🎮'}</span>
|
||||
<span className={css({ fontSize: 'lg', fontWeight: '600' })}>
|
||||
{player?.name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: isWinner ? 'orange.700' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
{score} {isWinner && '🏆'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Stats */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Game Stats
|
||||
</h3>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
|
||||
{state.roundNumber} {state.roundNumber === 1 ? 'round' : 'rounds'} played
|
||||
</p>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
|
||||
{state.guesses.length} {state.guesses.length === 1 ? 'guess' : 'guesses'} made
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Setup Phase - Game configuration
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, startGame, setConfig } = useNumberGuesser()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
🎯 Number Guesser Setup
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Game Rules
|
||||
</h3>
|
||||
<ul
|
||||
className={css({
|
||||
listStyle: 'disc',
|
||||
paddingLeft: '24px',
|
||||
lineHeight: '1.6',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<li>One player chooses a secret number</li>
|
||||
<li>Other players take turns guessing</li>
|
||||
<li>Get feedback on how close your guess is</li>
|
||||
<li>First to guess correctly wins the round!</li>
|
||||
<li>First to {state.roundsToWin} rounds wins the game!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Configuration
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Minimum Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.minNumber ?? 1}
|
||||
onChange={(e) => setConfig('minNumber', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Maximum Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.maxNumber ?? 100}
|
||||
onChange={(e) => setConfig('maxNumber', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Rounds to Win
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.roundsToWin ?? 3}
|
||||
onChange={(e) => setConfig('roundsToWin', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={startGame}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
name: number-guesser
|
||||
displayName: Number Guesser
|
||||
icon: 🎯
|
||||
description: Classic turn-based number guessing game
|
||||
longDescription: One player thinks of a number, others take turns guessing. Get hot/cold feedback as you try to find the secret number. Perfect for testing your deduction skills!
|
||||
maxPlayers: 4
|
||||
difficulty: Beginner
|
||||
chips:
|
||||
- 👥 Multiplayer
|
||||
- 🎲 Turn-Based
|
||||
- 🧠 Logic Puzzle
|
||||
color: orange
|
||||
gradient: linear-gradient(135deg, #fed7aa, #fdba74)
|
||||
borderColor: orange.200
|
||||
available: true
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Number Guesser Game Definition
|
||||
* Exports the complete game using the Arcade SDK
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { NumberGuesserProvider } from './Provider'
|
||||
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
|
||||
import { numberGuesserValidator } from './Validator'
|
||||
|
||||
// Game manifest (matches game.yaml)
|
||||
const manifest: GameManifest = {
|
||||
name: 'number-guesser',
|
||||
displayName: 'Number Guesser',
|
||||
icon: '🎯',
|
||||
description: 'Classic turn-based number guessing game',
|
||||
longDescription:
|
||||
'One player thinks of a number, others take turns guessing. Get hot/cold feedback to narrow down your guesses. First to guess wins the round!',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '🎲 Turn-Based', '🧠 Logic Puzzle'],
|
||||
color: 'orange',
|
||||
gradient: 'linear-gradient(135deg, #fed7aa, #fdba74)',
|
||||
borderColor: 'orange.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
const defaultConfig: NumberGuesserConfig = {
|
||||
minNumber: 1,
|
||||
maxNumber: 100,
|
||||
roundsToWin: 3,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateNumberGuesserConfig(config: unknown): config is NumberGuesserConfig {
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
'minNumber' in config &&
|
||||
'maxNumber' in config &&
|
||||
'roundsToWin' in config &&
|
||||
typeof config.minNumber === 'number' &&
|
||||
typeof config.maxNumber === 'number' &&
|
||||
typeof config.roundsToWin === 'number' &&
|
||||
config.minNumber >= 1 &&
|
||||
config.maxNumber > config.minNumber &&
|
||||
config.roundsToWin >= 1
|
||||
)
|
||||
}
|
||||
|
||||
// Export game definition
|
||||
export const numberGuesserGame = defineGame<
|
||||
NumberGuesserConfig,
|
||||
NumberGuesserState,
|
||||
NumberGuesserMove
|
||||
>({
|
||||
manifest,
|
||||
Provider: NumberGuesserProvider,
|
||||
GameComponent,
|
||||
validator: numberGuesserValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateNumberGuesserConfig,
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Type definitions for Number Guesser game
|
||||
*/
|
||||
|
||||
import type { GameMove } from '@/lib/arcade/game-sdk'
|
||||
|
||||
/**
|
||||
* Game configuration
|
||||
*/
|
||||
export type NumberGuesserConfig = {
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single guess attempt
|
||||
*/
|
||||
export interface Guess {
|
||||
playerId: string
|
||||
playerName: string
|
||||
guess: number
|
||||
distance: number // How far from the secret number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Game phases
|
||||
*/
|
||||
export type GamePhase = 'setup' | 'choosing' | 'guessing' | 'results'
|
||||
|
||||
/**
|
||||
* Game state
|
||||
*/
|
||||
export type NumberGuesserState = {
|
||||
// Configuration
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
|
||||
// Game phase
|
||||
gamePhase: GamePhase
|
||||
|
||||
// Players
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
|
||||
|
||||
// Current round
|
||||
secretNumber: number | null
|
||||
chooser: string // Player ID who chose the number
|
||||
currentGuesser: string // Player ID whose turn it is to guess
|
||||
|
||||
// Round history
|
||||
guesses: Guess[]
|
||||
roundNumber: number
|
||||
|
||||
// Scores
|
||||
scores: Record<string, number>
|
||||
|
||||
// Game state
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
winner: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Game moves
|
||||
*/
|
||||
export interface StartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChooseNumberMove extends GameMove {
|
||||
type: 'CHOOSE_NUMBER'
|
||||
data: {
|
||||
secretNumber: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MakeGuessMove extends GameMove {
|
||||
type: 'MAKE_GUESS'
|
||||
data: {
|
||||
guess: number
|
||||
playerName: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextRoundMove extends GameMove {
|
||||
type: 'NEXT_ROUND'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface GoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'minNumber' | 'maxNumber' | 'roundsToWin'
|
||||
value: number
|
||||
}
|
||||
}
|
||||
|
||||
export type NumberGuesserMove =
|
||||
| StartGameMove
|
||||
| ChooseNumberMove
|
||||
| MakeGuessMove
|
||||
| NextRoundMove
|
||||
| GoToSetupMove
|
||||
| SetConfigMove
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Shared game configuration types
|
||||
*
|
||||
* ARCHITECTURE: Phase 3 - Type Inference
|
||||
* - Modern games (number-guesser, math-sprint, memory-quiz, matching): Types inferred from game definitions
|
||||
* - Modern games (memory-quiz, matching): Types inferred from game definitions
|
||||
* - Legacy games (complement-race): Manual types until migrated
|
||||
*
|
||||
* These types are used across:
|
||||
@@ -13,10 +13,9 @@
|
||||
*/
|
||||
|
||||
// Type-only imports (won't load React components at runtime)
|
||||
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
import type { mathSprintGame } from '@/arcade-games/math-sprint'
|
||||
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
|
||||
@@ -28,18 +27,6 @@ type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : n
|
||||
// Modern Games (Type Inference from Game Definitions)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for number-guesser game
|
||||
* INFERRED from numberGuesserGame.defaultConfig
|
||||
*/
|
||||
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
|
||||
|
||||
/**
|
||||
* Configuration for math-sprint game
|
||||
* INFERRED from mathSprintGame.defaultConfig
|
||||
*/
|
||||
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
|
||||
|
||||
/**
|
||||
* Configuration for memory-quiz (soroban lightning) game
|
||||
* INFERRED from memoryQuizGame.defaultConfig
|
||||
@@ -52,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
|
||||
@@ -108,10 +101,9 @@ export interface ComplementRaceGameConfig {
|
||||
*/
|
||||
export type GameConfigByName = {
|
||||
// Modern games (inferred types)
|
||||
'number-guesser': NumberGuesserGameConfig
|
||||
'math-sprint': MathSprintGameConfig
|
||||
'memory-quiz': MemoryQuizGameConfig
|
||||
matching: MatchingGameConfig
|
||||
'card-sorting': CardSortingGameConfig
|
||||
|
||||
// Legacy games (manual types)
|
||||
'complement-race': ComplementRaceGameConfig
|
||||
@@ -143,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',
|
||||
@@ -176,15 +174,3 @@ export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
|
||||
targetScore: 100,
|
||||
timeLimit: 300,
|
||||
}
|
||||
|
||||
export const DEFAULT_NUMBER_GUESSER_CONFIG: NumberGuesserGameConfig = {
|
||||
minNumber: 1,
|
||||
maxNumber: 100,
|
||||
roundsToWin: 3,
|
||||
}
|
||||
|
||||
export const DEFAULT_MATH_SPRINT_CONFIG: MathSprintGameConfig = {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
}
|
||||
|
||||
@@ -106,14 +106,12 @@ export function clearRegistry(): void {
|
||||
// Game Registrations
|
||||
// ============================================================================
|
||||
|
||||
import { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
import { mathSprintGame } from '@/arcade-games/math-sprint'
|
||||
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(numberGuesserGame)
|
||||
registerGame(mathSprintGame)
|
||||
registerGame(memoryQuizGame)
|
||||
registerGame(matchingGame)
|
||||
registerGame(complementRaceGame)
|
||||
registerGame(cardSortingGame)
|
||||
|
||||
@@ -12,7 +12,7 @@ export {
|
||||
validatorRegistry,
|
||||
matchingGameValidator,
|
||||
memoryQuizGameValidator,
|
||||
numberGuesserValidator,
|
||||
cardSortingValidator,
|
||||
} from '../validators'
|
||||
|
||||
export type { GameName } from '../validators'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
|
||||
import { matchingGameValidator } from '@/arcade-games/matching/Validator'
|
||||
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
|
||||
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
|
||||
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
|
||||
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
|
||||
import { cardSortingValidator } from '@/arcade-games/card-sorting/Validator'
|
||||
import type { GameValidator } from './validation/types'
|
||||
|
||||
/**
|
||||
@@ -25,9 +24,8 @@ import type { GameValidator } from './validation/types'
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
'math-sprint': mathSprintValidator,
|
||||
'complement-race': complementRaceValidator,
|
||||
'card-sorting': cardSortingValidator,
|
||||
// Add new games here - GameName type will auto-update
|
||||
} as const
|
||||
|
||||
@@ -97,7 +95,6 @@ export function assertValidGameName(gameName: unknown): asserts gameName is Game
|
||||
export {
|
||||
matchingGameValidator,
|
||||
memoryQuizGameValidator,
|
||||
numberGuesserValidator,
|
||||
mathSprintValidator,
|
||||
complementRaceValidator,
|
||||
cardSortingValidator,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.6.2",
|
||||
"version": "4.9.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
17
packages/core/client/browser/node_modules/.bin/tsc
generated
vendored
17
packages/core/client/browser/node_modules/.bin/tsc
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
17
packages/core/client/browser/node_modules/.bin/tsserver
generated
vendored
17
packages/core/client/browser/node_modules/.bin/tsserver
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
17
packages/core/client/browser/node_modules/.bin/vite
generated
vendored
17
packages/core/client/browser/node_modules/.bin/vite
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../vite/bin/vite.js" "$@"
|
||||
fi
|
||||
1
packages/core/client/browser/node_modules/@myriaddreamin/typst-ts-renderer
generated
vendored
1
packages/core/client/browser/node_modules/@myriaddreamin/typst-ts-renderer
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../../node_modules/.pnpm/@myriaddreamin+typst-ts-renderer@0.6.0/node_modules/@myriaddreamin/typst-ts-renderer
|
||||
1
packages/core/client/browser/node_modules/@myriaddreamin/typst-ts-web-compiler
generated
vendored
1
packages/core/client/browser/node_modules/@myriaddreamin/typst-ts-web-compiler
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../../node_modules/.pnpm/@myriaddreamin+typst-ts-web-compiler@0.6.0/node_modules/@myriaddreamin/typst-ts-web-compiler
|
||||
1
packages/core/client/browser/node_modules/vite
generated
vendored
1
packages/core/client/browser/node_modules/vite
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "soroban-flashcards-browser",
|
||||
"version": "1.0.0",
|
||||
"description": "Browser-based Soroban Flashcard Generator using Typst.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@myriaddreamin/typst-ts-web-compiler": "^0.6.0",
|
||||
"@myriaddreamin/typst-ts-renderer": "^0.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
81
packages/core/client/node/dist/index.d.ts
vendored
81
packages/core/client/node/dist/index.d.ts
vendored
@@ -2,12 +2,12 @@
|
||||
* Node.js TypeScript wrapper for Soroban Flashcard Generator
|
||||
* Calls Python functions via child_process
|
||||
*/
|
||||
interface FlashcardConfig$1 {
|
||||
interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
paperSize?: "us-letter" | "a4" | "a3" | "a5";
|
||||
orientation?: "portrait" | "landscape";
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
@@ -24,12 +24,12 @@ interface FlashcardConfig$1 {
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: 'diamond' | 'circle' | 'square';
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
|
||||
beadShape?: "diamond" | "circle" | "square";
|
||||
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
declare class SorobanGenerator$1 {
|
||||
declare class SorobanGenerator {
|
||||
private pythonPath;
|
||||
private generatorPath;
|
||||
private projectRoot;
|
||||
@@ -38,15 +38,15 @@ declare class SorobanGenerator$1 {
|
||||
/**
|
||||
* Generate flashcards and return PDF as Buffer
|
||||
*/
|
||||
generate(config: FlashcardConfig$1): Promise<Buffer>;
|
||||
generate(config: FlashcardConfig): Promise<Buffer>;
|
||||
/**
|
||||
* Generate flashcards and save to file
|
||||
*/
|
||||
generateToFile(config: FlashcardConfig$1, outputPath: string): Promise<void>;
|
||||
generateToFile(config: FlashcardConfig, outputPath: string): Promise<void>;
|
||||
/**
|
||||
* Generate flashcards and return as base64 string
|
||||
*/
|
||||
generateBase64(config: FlashcardConfig$1): Promise<string>;
|
||||
generateBase64(config: FlashcardConfig): Promise<string>;
|
||||
private executePython;
|
||||
/**
|
||||
* Check if all dependencies are installed
|
||||
@@ -59,65 +59,4 @@ declare class SorobanGenerator$1 {
|
||||
}
|
||||
declare function expressExample(): Promise<void>;
|
||||
|
||||
/**
|
||||
* TypeScript wrapper using python-shell for clean function interface
|
||||
* No CLI arguments - just function calls with objects
|
||||
*/
|
||||
interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
gutter?: string;
|
||||
shuffle?: boolean;
|
||||
seed?: number;
|
||||
showCutMarks?: boolean;
|
||||
showRegistration?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: 'diamond' | 'circle' | 'square';
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
format?: 'pdf' | 'svg';
|
||||
mode?: 'single-card' | 'flashcards';
|
||||
number?: number;
|
||||
}
|
||||
interface FlashcardResult {
|
||||
pdf: string;
|
||||
count: number;
|
||||
numbers: number[];
|
||||
}
|
||||
declare class SorobanGenerator {
|
||||
private pythonShell;
|
||||
private projectRoot;
|
||||
constructor(projectRoot?: string);
|
||||
/**
|
||||
* Initialize persistent Python process for better performance
|
||||
*/
|
||||
initialize(): Promise<void>;
|
||||
/**
|
||||
* Generate flashcards - clean function interface
|
||||
*/
|
||||
generate(config: FlashcardConfig): Promise<FlashcardResult>;
|
||||
/**
|
||||
* Generate and return as Buffer
|
||||
*/
|
||||
generateBuffer(config: FlashcardConfig): Promise<Buffer>;
|
||||
/**
|
||||
* Clean up Python process
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export { FlashcardConfig as BridgeFlashcardConfig, FlashcardResult as BridgeFlashcardResult, FlashcardConfig$1 as FlashcardConfig, SorobanGenerator$1 as SorobanGenerator, SorobanGenerator as SorobanGeneratorBridge, SorobanGenerator$1 as default, expressExample };
|
||||
export { type FlashcardConfig, SorobanGenerator, SorobanGenerator as default, expressExample };
|
||||
|
||||
89
packages/core/client/node/dist/index.js
vendored
89
packages/core/client/node/dist/index.js
vendored
@@ -31,7 +31,6 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
||||
var src_exports = {};
|
||||
__export(src_exports, {
|
||||
SorobanGenerator: () => SorobanGenerator,
|
||||
SorobanGeneratorBridge: () => SorobanGenerator2,
|
||||
default: () => SorobanGenerator,
|
||||
expressExample: () => expressExample
|
||||
});
|
||||
@@ -194,96 +193,8 @@ var SorobanGenerator = class {
|
||||
async function expressExample() {
|
||||
const generator = new SorobanGenerator();
|
||||
}
|
||||
|
||||
// src/soroban-generator-bridge.ts
|
||||
var import_python_shell = require("python-shell");
|
||||
var path2 = __toESM(require("path"));
|
||||
var SorobanGenerator2 = class {
|
||||
pythonShell = null;
|
||||
projectRoot;
|
||||
constructor(projectRoot) {
|
||||
this.projectRoot = projectRoot || path2.join(__dirname, "../../");
|
||||
}
|
||||
/**
|
||||
* Initialize persistent Python process for better performance
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.pythonShell)
|
||||
return;
|
||||
this.pythonShell = new import_python_shell.PythonShell(path2.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
pythonOptions: ["-u"],
|
||||
// Unbuffered
|
||||
scriptPath: this.projectRoot
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate flashcards - clean function interface
|
||||
*/
|
||||
async generate(config) {
|
||||
if (!this.pythonShell) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const shell = new import_python_shell.PythonShell(path2.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
scriptPath: this.projectRoot
|
||||
});
|
||||
shell.on("message", (message) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
});
|
||||
shell.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
shell.send(config);
|
||||
shell.end((err, code, signal) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.pythonShell) {
|
||||
reject(new Error("Not initialized"));
|
||||
return;
|
||||
}
|
||||
const handler = (message) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
this.pythonShell?.removeListener("message", handler);
|
||||
};
|
||||
this.pythonShell.on("message", handler);
|
||||
this.pythonShell.send(config);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate and return as Buffer
|
||||
*/
|
||||
async generateBuffer(config) {
|
||||
const result = await this.generate(config);
|
||||
return Buffer.from(result.pdf, "base64");
|
||||
}
|
||||
/**
|
||||
* Clean up Python process
|
||||
*/
|
||||
async close() {
|
||||
if (this.pythonShell) {
|
||||
this.pythonShell.end(() => {
|
||||
});
|
||||
this.pythonShell = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
SorobanGenerator,
|
||||
SorobanGeneratorBridge,
|
||||
expressExample
|
||||
});
|
||||
|
||||
88
packages/core/client/node/dist/index.mjs
vendored
88
packages/core/client/node/dist/index.mjs
vendored
@@ -155,96 +155,8 @@ var SorobanGenerator = class {
|
||||
async function expressExample() {
|
||||
const generator = new SorobanGenerator();
|
||||
}
|
||||
|
||||
// src/soroban-generator-bridge.ts
|
||||
import { PythonShell } from "python-shell";
|
||||
import * as path2 from "path";
|
||||
var SorobanGenerator2 = class {
|
||||
pythonShell = null;
|
||||
projectRoot;
|
||||
constructor(projectRoot) {
|
||||
this.projectRoot = projectRoot || path2.join(__dirname, "../../");
|
||||
}
|
||||
/**
|
||||
* Initialize persistent Python process for better performance
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.pythonShell)
|
||||
return;
|
||||
this.pythonShell = new PythonShell(path2.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
pythonOptions: ["-u"],
|
||||
// Unbuffered
|
||||
scriptPath: this.projectRoot
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate flashcards - clean function interface
|
||||
*/
|
||||
async generate(config) {
|
||||
if (!this.pythonShell) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const shell = new PythonShell(path2.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
scriptPath: this.projectRoot
|
||||
});
|
||||
shell.on("message", (message) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
});
|
||||
shell.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
shell.send(config);
|
||||
shell.end((err, code, signal) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.pythonShell) {
|
||||
reject(new Error("Not initialized"));
|
||||
return;
|
||||
}
|
||||
const handler = (message) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
this.pythonShell?.removeListener("message", handler);
|
||||
};
|
||||
this.pythonShell.on("message", handler);
|
||||
this.pythonShell.send(config);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate and return as Buffer
|
||||
*/
|
||||
async generateBuffer(config) {
|
||||
const result = await this.generate(config);
|
||||
return Buffer.from(result.pdf, "base64");
|
||||
}
|
||||
/**
|
||||
* Clean up Python process
|
||||
*/
|
||||
async close() {
|
||||
if (this.pythonShell) {
|
||||
this.pythonShell.end(() => {
|
||||
});
|
||||
this.pythonShell = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
export {
|
||||
SorobanGenerator,
|
||||
SorobanGenerator2 as SorobanGeneratorBridge,
|
||||
SorobanGenerator as default,
|
||||
expressExample
|
||||
};
|
||||
|
||||
6
packages/core/client/node/node_modules/.bin/esbuild
generated
vendored
6
packages/core/client/node/node_modules/.bin/esbuild
generated
vendored
@@ -6,9 +6,9 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/esbuild" "$@"
|
||||
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/esbuild" "$@"
|
||||
exit $?
|
||||
|
||||
1
packages/core/client/node/node_modules/python-shell
generated
vendored
1
packages/core/client/node/node_modules/python-shell
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../node_modules/.pnpm/python-shell@5.0.0/node_modules/python-shell
|
||||
@@ -18,11 +18,7 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"python-shell": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimatch": "^6.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^7.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
|
||||
@@ -5,12 +5,5 @@
|
||||
|
||||
export * from "./soroban-generator";
|
||||
|
||||
// Export bridge generator with different name to avoid conflicts
|
||||
export {
|
||||
SorobanGenerator as SorobanGeneratorBridge,
|
||||
FlashcardConfig as BridgeFlashcardConfig,
|
||||
FlashcardResult as BridgeFlashcardResult,
|
||||
} from "./soroban-generator-bridge";
|
||||
|
||||
// Default export for convenience
|
||||
export { SorobanGenerator as default } from "./soroban-generator";
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* TypeScript wrapper using python-shell for clean function interface
|
||||
* No CLI arguments - just function calls with objects
|
||||
*/
|
||||
|
||||
import { PythonShell } from "python-shell";
|
||||
import * as path from "path";
|
||||
|
||||
export interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: "us-letter" | "a4" | "a3" | "a5";
|
||||
orientation?: "portrait" | "landscape";
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
gutter?: string;
|
||||
shuffle?: boolean;
|
||||
seed?: number;
|
||||
showCutMarks?: boolean;
|
||||
showRegistration?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: "diamond" | "circle" | "square";
|
||||
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
format?: "pdf" | "svg";
|
||||
mode?: "single-card" | "flashcards";
|
||||
number?: number;
|
||||
}
|
||||
|
||||
export interface FlashcardResult {
|
||||
pdf: string; // base64 encoded PDF or SVG content (depending on format)
|
||||
count: number;
|
||||
numbers: number[];
|
||||
}
|
||||
|
||||
export class SorobanGenerator {
|
||||
private pythonShell: PythonShell | null = null;
|
||||
private projectRoot: string;
|
||||
|
||||
constructor(projectRoot?: string) {
|
||||
this.projectRoot = projectRoot || path.join(__dirname, "../../");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize persistent Python process for better performance
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.pythonShell) return;
|
||||
|
||||
this.pythonShell = new PythonShell(path.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
pythonOptions: ["-u"], // Unbuffered
|
||||
scriptPath: this.projectRoot,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flashcards - clean function interface
|
||||
*/
|
||||
async generate(config: FlashcardConfig): Promise<FlashcardResult> {
|
||||
// One-shot mode if not initialized
|
||||
if (!this.pythonShell) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const shell = new PythonShell(path.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
scriptPath: this.projectRoot,
|
||||
});
|
||||
|
||||
shell.on("message", (message: any) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message as FlashcardResult);
|
||||
}
|
||||
});
|
||||
|
||||
shell.on("error", (err: any) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
shell.send(config);
|
||||
shell.end((err: any, code: any, signal: any) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Persistent mode
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.pythonShell) {
|
||||
reject(new Error("Not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (message: any) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message as FlashcardResult);
|
||||
}
|
||||
this.pythonShell?.removeListener("message", handler);
|
||||
};
|
||||
|
||||
this.pythonShell.on("message", handler);
|
||||
this.pythonShell.send(config);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and return as Buffer
|
||||
*/
|
||||
async generateBuffer(config: FlashcardConfig): Promise<Buffer> {
|
||||
const result = await this.generate(config);
|
||||
return Buffer.from(result.pdf, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up Python process
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.pythonShell) {
|
||||
this.pythonShell.end(() => {});
|
||||
this.pythonShell = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage - just like calling a regular TypeScript function
|
||||
async function example() {
|
||||
const generator = new SorobanGenerator();
|
||||
|
||||
// Just call it like a function!
|
||||
const result = await generator.generate({
|
||||
range: "0-99",
|
||||
cardsPerPage: 6,
|
||||
colorScheme: "place-value",
|
||||
coloredNumerals: true,
|
||||
showCutMarks: true,
|
||||
});
|
||||
|
||||
// You get back a clean result object
|
||||
console.log(`Generated ${result.count} flashcards`);
|
||||
|
||||
// Convert to Buffer if needed
|
||||
const pdfBuffer = Buffer.from(result.pdf, "base64");
|
||||
|
||||
// Or use persistent mode for better performance
|
||||
await generator.initialize();
|
||||
|
||||
// Now calls are faster
|
||||
const result2 = await generator.generate({ range: "0-9" });
|
||||
const result3 = await generator.generate({ range: "10-19" });
|
||||
|
||||
await generator.close();
|
||||
}
|
||||
|
||||
// Express example - clean function calls
|
||||
export function expressRoute(app: any) {
|
||||
const generator = new SorobanGenerator();
|
||||
|
||||
app.post("/api/flashcards", async (req: any, res: any) => {
|
||||
try {
|
||||
// Just pass the config object directly!
|
||||
const result = await generator.generate(req.body);
|
||||
|
||||
// Send back JSON or PDF
|
||||
if (req.query.format === "json") {
|
||||
res.json(result);
|
||||
} else {
|
||||
const pdfBuffer = Buffer.from(result.pdf, "base64");
|
||||
res.contentType("application/pdf");
|
||||
res.send(pdfBuffer);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
58
packages/core/client/typescript/dist/index.d.ts
vendored
58
packages/core/client/typescript/dist/index.d.ts
vendored
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* TypeScript client for Soroban Flashcard Generator API
|
||||
*/
|
||||
interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
gutter?: string;
|
||||
shuffle?: boolean;
|
||||
seed?: number;
|
||||
showCutMarks?: boolean;
|
||||
showRegistration?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: 'diamond' | 'circle' | 'square';
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
interface FlashcardResponse {
|
||||
pdf: string;
|
||||
count: number;
|
||||
numbers: number[];
|
||||
}
|
||||
declare class SorobanFlashcardClient {
|
||||
private apiUrl;
|
||||
constructor(apiUrl?: string);
|
||||
/**
|
||||
* Generate flashcards and return as base64 PDF
|
||||
*/
|
||||
generate(config: FlashcardConfig): Promise<FlashcardResponse>;
|
||||
/**
|
||||
* Generate flashcards and download as PDF file
|
||||
*/
|
||||
generateAndDownload(config: FlashcardConfig, filename?: string): Promise<void>;
|
||||
/**
|
||||
* Generate flashcards and open in new tab
|
||||
*/
|
||||
generateAndOpen(config: FlashcardConfig): Promise<void>;
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
health(): Promise<boolean>;
|
||||
}
|
||||
declare function example(): Promise<void>;
|
||||
|
||||
export { FlashcardConfig, FlashcardResponse, SorobanFlashcardClient, SorobanFlashcardClient as default, example };
|
||||
148
packages/core/client/typescript/dist/index.js
vendored
148
packages/core/client/typescript/dist/index.js
vendored
@@ -1,148 +0,0 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/index.ts
|
||||
var src_exports = {};
|
||||
__export(src_exports, {
|
||||
SorobanFlashcardClient: () => SorobanFlashcardClient,
|
||||
default: () => SorobanFlashcardClient,
|
||||
example: () => example
|
||||
});
|
||||
module.exports = __toCommonJS(src_exports);
|
||||
|
||||
// src/soroban-flashcards.ts
|
||||
var SorobanFlashcardClient = class {
|
||||
apiUrl;
|
||||
constructor(apiUrl = "http://localhost:8000") {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and return as base64 PDF
|
||||
*/
|
||||
async generate(config) {
|
||||
const response = await fetch(`${this.apiUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
range: config.range,
|
||||
step: config.step ?? 1,
|
||||
cards_per_page: config.cardsPerPage ?? 6,
|
||||
paper_size: config.paperSize ?? "us-letter",
|
||||
orientation: config.orientation ?? "portrait",
|
||||
margins: config.margins ?? {
|
||||
top: "0.5in",
|
||||
bottom: "0.5in",
|
||||
left: "0.5in",
|
||||
right: "0.5in"
|
||||
},
|
||||
gutter: config.gutter ?? "5mm",
|
||||
shuffle: config.shuffle ?? false,
|
||||
seed: config.seed,
|
||||
show_cut_marks: config.showCutMarks ?? false,
|
||||
show_registration: config.showRegistration ?? false,
|
||||
font_family: config.fontFamily ?? "DejaVu Sans",
|
||||
font_size: config.fontSize ?? "48pt",
|
||||
columns: config.columns ?? "auto",
|
||||
show_empty_columns: config.showEmptyColumns ?? false,
|
||||
hide_inactive_beads: config.hideInactiveBeads ?? false,
|
||||
bead_shape: config.beadShape ?? "diamond",
|
||||
color_scheme: config.colorScheme ?? "monochrome",
|
||||
colored_numerals: config.coloredNumerals ?? false,
|
||||
scale_factor: config.scaleFactor ?? 0.9,
|
||||
format: "base64"
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to generate flashcards");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and download as PDF file
|
||||
*/
|
||||
async generateAndDownload(config, filename = "flashcards.pdf") {
|
||||
const result = await this.generate(config);
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and open in new tab
|
||||
*/
|
||||
async generateAndOpen(config) {
|
||||
const result = await this.generate(config);
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
async health() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/health`);
|
||||
const data = await response.json();
|
||||
return data.status === "healthy";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
async function example() {
|
||||
const client = new SorobanFlashcardClient();
|
||||
await client.generateAndDownload({
|
||||
range: "0-99",
|
||||
cardsPerPage: 6,
|
||||
colorScheme: "place-value",
|
||||
coloredNumerals: true,
|
||||
showCutMarks: true
|
||||
});
|
||||
await client.generateAndDownload(
|
||||
{
|
||||
range: "0-100",
|
||||
step: 5,
|
||||
cardsPerPage: 6
|
||||
},
|
||||
"counting-by-5s.pdf"
|
||||
);
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
SorobanFlashcardClient,
|
||||
example
|
||||
});
|
||||
120
packages/core/client/typescript/dist/index.mjs
vendored
120
packages/core/client/typescript/dist/index.mjs
vendored
@@ -1,120 +0,0 @@
|
||||
// src/soroban-flashcards.ts
|
||||
var SorobanFlashcardClient = class {
|
||||
apiUrl;
|
||||
constructor(apiUrl = "http://localhost:8000") {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and return as base64 PDF
|
||||
*/
|
||||
async generate(config) {
|
||||
const response = await fetch(`${this.apiUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
range: config.range,
|
||||
step: config.step ?? 1,
|
||||
cards_per_page: config.cardsPerPage ?? 6,
|
||||
paper_size: config.paperSize ?? "us-letter",
|
||||
orientation: config.orientation ?? "portrait",
|
||||
margins: config.margins ?? {
|
||||
top: "0.5in",
|
||||
bottom: "0.5in",
|
||||
left: "0.5in",
|
||||
right: "0.5in"
|
||||
},
|
||||
gutter: config.gutter ?? "5mm",
|
||||
shuffle: config.shuffle ?? false,
|
||||
seed: config.seed,
|
||||
show_cut_marks: config.showCutMarks ?? false,
|
||||
show_registration: config.showRegistration ?? false,
|
||||
font_family: config.fontFamily ?? "DejaVu Sans",
|
||||
font_size: config.fontSize ?? "48pt",
|
||||
columns: config.columns ?? "auto",
|
||||
show_empty_columns: config.showEmptyColumns ?? false,
|
||||
hide_inactive_beads: config.hideInactiveBeads ?? false,
|
||||
bead_shape: config.beadShape ?? "diamond",
|
||||
color_scheme: config.colorScheme ?? "monochrome",
|
||||
colored_numerals: config.coloredNumerals ?? false,
|
||||
scale_factor: config.scaleFactor ?? 0.9,
|
||||
format: "base64"
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to generate flashcards");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and download as PDF file
|
||||
*/
|
||||
async generateAndDownload(config, filename = "flashcards.pdf") {
|
||||
const result = await this.generate(config);
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and open in new tab
|
||||
*/
|
||||
async generateAndOpen(config) {
|
||||
const result = await this.generate(config);
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
async health() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/health`);
|
||||
const data = await response.json();
|
||||
return data.status === "healthy";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
async function example() {
|
||||
const client = new SorobanFlashcardClient();
|
||||
await client.generateAndDownload({
|
||||
range: "0-99",
|
||||
cardsPerPage: 6,
|
||||
colorScheme: "place-value",
|
||||
coloredNumerals: true,
|
||||
showCutMarks: true
|
||||
});
|
||||
await client.generateAndDownload(
|
||||
{
|
||||
range: "0-100",
|
||||
step: 5,
|
||||
cardsPerPage: 6
|
||||
},
|
||||
"counting-by-5s.pdf"
|
||||
);
|
||||
}
|
||||
export {
|
||||
SorobanFlashcardClient,
|
||||
SorobanFlashcardClient as default,
|
||||
example
|
||||
};
|
||||
14
packages/core/client/typescript/node_modules/.bin/esbuild
generated
vendored
14
packages/core/client/typescript/node_modules/.bin/esbuild
generated
vendored
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/esbuild" "$@"
|
||||
exit $?
|
||||
17
packages/core/client/typescript/node_modules/.bin/tsc
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/tsc
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
17
packages/core/client/typescript/node_modules/.bin/tsserver
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/tsserver
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
17
packages/core/client/typescript/node_modules/.bin/tsup
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/tsup
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../tsup/dist/cli-default.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../tsup/dist/cli-default.js" "$@"
|
||||
fi
|
||||
17
packages/core/client/typescript/node_modules/.bin/tsup-node
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/tsup-node
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../tsup/dist/cli-node.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../tsup/dist/cli-node.js" "$@"
|
||||
fi
|
||||
17
packages/core/client/typescript/node_modules/.bin/vitest
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/vitest
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
||||
fi
|
||||
1
packages/core/client/typescript/node_modules/tsup
generated
vendored
1
packages/core/client/typescript/node_modules/tsup
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup
|
||||
1
packages/core/client/typescript/node_modules/vitest
generated
vendored
1
packages/core/client/typescript/node_modules/vitest
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "@soroban/client",
|
||||
"version": "1.0.0",
|
||||
"description": "TypeScript client for Soroban Flashcard Generator",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.esm.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
||||
"test": "vitest",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"keywords": [
|
||||
"soroban",
|
||||
"abacus",
|
||||
"flashcards",
|
||||
"education",
|
||||
"math"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/minimatch": "^6.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^7.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Soroban Flashcard Generator - TypeScript Client
|
||||
* Re-export main client functionality
|
||||
*/
|
||||
|
||||
export * from "./soroban-flashcards";
|
||||
export { SorobanFlashcardClient as default } from "./soroban-flashcards";
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* TypeScript client for Soroban Flashcard Generator API
|
||||
*/
|
||||
|
||||
export interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: "us-letter" | "a4" | "a3" | "a5";
|
||||
orientation?: "portrait" | "landscape";
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
gutter?: string;
|
||||
shuffle?: boolean;
|
||||
seed?: number;
|
||||
showCutMarks?: boolean;
|
||||
showRegistration?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: "diamond" | "circle" | "square";
|
||||
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
export interface FlashcardResponse {
|
||||
pdf: string; // base64 encoded PDF
|
||||
count: number;
|
||||
numbers: number[];
|
||||
}
|
||||
|
||||
export class SorobanFlashcardClient {
|
||||
private apiUrl: string;
|
||||
|
||||
constructor(apiUrl: string = "http://localhost:8000") {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flashcards and return as base64 PDF
|
||||
*/
|
||||
async generate(config: FlashcardConfig): Promise<FlashcardResponse> {
|
||||
const response = await fetch(`${this.apiUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
range: config.range,
|
||||
step: config.step ?? 1,
|
||||
cards_per_page: config.cardsPerPage ?? 6,
|
||||
paper_size: config.paperSize ?? "us-letter",
|
||||
orientation: config.orientation ?? "portrait",
|
||||
margins: config.margins ?? {
|
||||
top: "0.5in",
|
||||
bottom: "0.5in",
|
||||
left: "0.5in",
|
||||
right: "0.5in",
|
||||
},
|
||||
gutter: config.gutter ?? "5mm",
|
||||
shuffle: config.shuffle ?? false,
|
||||
seed: config.seed,
|
||||
show_cut_marks: config.showCutMarks ?? false,
|
||||
show_registration: config.showRegistration ?? false,
|
||||
font_family: config.fontFamily ?? "DejaVu Sans",
|
||||
font_size: config.fontSize ?? "48pt",
|
||||
columns: config.columns ?? "auto",
|
||||
show_empty_columns: config.showEmptyColumns ?? false,
|
||||
hide_inactive_beads: config.hideInactiveBeads ?? false,
|
||||
bead_shape: config.beadShape ?? "diamond",
|
||||
color_scheme: config.colorScheme ?? "monochrome",
|
||||
colored_numerals: config.coloredNumerals ?? false,
|
||||
scale_factor: config.scaleFactor ?? 0.9,
|
||||
format: "base64",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to generate flashcards");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flashcards and download as PDF file
|
||||
*/
|
||||
async generateAndDownload(
|
||||
config: FlashcardConfig,
|
||||
filename: string = "flashcards.pdf",
|
||||
): Promise<void> {
|
||||
const result = await this.generate(config);
|
||||
|
||||
// Convert base64 to blob
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flashcards and open in new tab
|
||||
*/
|
||||
async generateAndOpen(config: FlashcardConfig): Promise<void> {
|
||||
const result = await this.generate(config);
|
||||
|
||||
// Convert base64 to blob
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
|
||||
// Open in new tab
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/health`);
|
||||
const data = await response.json();
|
||||
return data.status === "healthy";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage function
|
||||
export async function example() {
|
||||
const client = new SorobanFlashcardClient();
|
||||
|
||||
// Generate and download flashcards for 0-99 with place-value coloring
|
||||
await client.generateAndDownload({
|
||||
range: "0-99",
|
||||
cardsPerPage: 6,
|
||||
colorScheme: "place-value",
|
||||
coloredNumerals: true,
|
||||
showCutMarks: true,
|
||||
});
|
||||
|
||||
// Generate counting by 5s
|
||||
await client.generateAndDownload(
|
||||
{
|
||||
range: "0-100",
|
||||
step: 5,
|
||||
cardsPerPage: 6,
|
||||
},
|
||||
"counting-by-5s.pdf",
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python bridge for Node.js integration
|
||||
Provides a clean function interface instead of CLI
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import base64
|
||||
import tempfile
|
||||
import os
|
||||
import glob
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
# Import our existing functions
|
||||
from generate import parse_range, generate_typst_file, generate_single_card_typst
|
||||
|
||||
def generate_flashcards_json(config_json):
|
||||
"""
|
||||
Generate flashcards from JSON config
|
||||
Returns base64 encoded PDF
|
||||
"""
|
||||
config = json.loads(config_json)
|
||||
|
||||
# Parse numbers
|
||||
numbers = parse_range(
|
||||
config.get('range', '0-9'),
|
||||
config.get('step', 1)
|
||||
)
|
||||
|
||||
# Handle shuffle
|
||||
if config.get('shuffle', False):
|
||||
import random
|
||||
if 'seed' in config:
|
||||
random.seed(config['seed'])
|
||||
random.shuffle(numbers)
|
||||
|
||||
# Build Typst config
|
||||
typst_config = {
|
||||
'cards_per_page': config.get('cardsPerPage', 6),
|
||||
'paper_size': config.get('paperSize', 'us-letter'),
|
||||
'orientation': config.get('orientation', 'portrait'),
|
||||
'margins': config.get('margins', {
|
||||
'top': '0.5in',
|
||||
'bottom': '0.5in',
|
||||
'left': '0.5in',
|
||||
'right': '0.5in'
|
||||
}),
|
||||
'gutter': config.get('gutter', '5mm'),
|
||||
'show_cut_marks': config.get('showCutMarks', False),
|
||||
'show_registration': config.get('showRegistration', False),
|
||||
'font_family': config.get('fontFamily', 'DejaVu Sans'),
|
||||
'font_size': config.get('fontSize', '48pt'),
|
||||
'columns': config.get('columns', 'auto'),
|
||||
'show_empty_columns': config.get('showEmptyColumns', False),
|
||||
'hide_inactive_beads': config.get('hideInactiveBeads', False),
|
||||
'bead_shape': config.get('beadShape', 'diamond'),
|
||||
'color_scheme': config.get('colorScheme', 'monochrome'),
|
||||
'colored_numerals': config.get('coloredNumerals', False),
|
||||
'scale_factor': config.get('scaleFactor', 0.9),
|
||||
}
|
||||
|
||||
# Generate in core package directory to match main generator behavior
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
# Generate Typst file - same setup as main generate.py
|
||||
core_package_root = Path(__file__).parent.parent # packages/core directory
|
||||
|
||||
# Create temp files in core package root, not temp directory
|
||||
temp_typst = core_package_root / f'temp_flashcards_{os.getpid()}.typ'
|
||||
temp_pdf = core_package_root / f'temp_flashcards_{os.getpid()}.pdf'
|
||||
|
||||
# Convert Python list to Typst array syntax (same as main generate.py)
|
||||
if numbers:
|
||||
numbers_str = '(' + ', '.join(str(n) for n in numbers) + ',)'
|
||||
else:
|
||||
numbers_str = '()'
|
||||
|
||||
# Create temp Typst with relative imports (works when run from core package root)
|
||||
typst_content = f'''
|
||||
#import "templates/flashcards.typ": generate-flashcards
|
||||
|
||||
#generate-flashcards(
|
||||
{numbers_str},
|
||||
cards-per-page: {typst_config['cards_per_page']},
|
||||
paper-size: "{typst_config['paper_size']}",
|
||||
orientation: "{typst_config['orientation']}",
|
||||
margins: (
|
||||
top: {typst_config['margins'].get('top', '0.5in')},
|
||||
bottom: {typst_config['margins'].get('bottom', '0.5in')},
|
||||
left: {typst_config['margins'].get('left', '0.5in')},
|
||||
right: {typst_config['margins'].get('right', '0.5in')}
|
||||
),
|
||||
gutter: {typst_config['gutter']},
|
||||
show-cut-marks: {str(typst_config['show_cut_marks']).lower()},
|
||||
show-registration: {str(typst_config['show_registration']).lower()},
|
||||
font-family: "{typst_config['font_family']}",
|
||||
font-size: {typst_config['font_size']},
|
||||
columns: {typst_config['columns']},
|
||||
show-empty-columns: {str(typst_config['show_empty_columns']).lower()},
|
||||
hide-inactive-beads: {str(typst_config['hide_inactive_beads']).lower()},
|
||||
bead-shape: "{typst_config['bead_shape']}",
|
||||
color-scheme: "{typst_config['color_scheme']}",
|
||||
colored-numerals: {str(typst_config['colored_numerals']).lower()},
|
||||
scale-factor: {typst_config['scale_factor']}
|
||||
)
|
||||
'''
|
||||
|
||||
with open(temp_typst, 'w') as f:
|
||||
f.write(typst_content)
|
||||
|
||||
# Get format preference
|
||||
output_format = config.get('format', 'pdf').lower()
|
||||
temp_svg = None
|
||||
|
||||
try:
|
||||
if output_format == 'svg':
|
||||
# Generate SVG using Typst with page template for multi-page support
|
||||
temp_svg = core_package_root / f'temp_flashcards_{os.getpid()}_{{p}}.svg'
|
||||
result = subprocess.run(
|
||||
['typst', 'compile', str(temp_typst), str(temp_svg), '--format', 'svg'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(core_package_root)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return json.dumps({
|
||||
'error': f'Typst SVG compilation failed: {result.stderr}'
|
||||
})
|
||||
|
||||
# Read SVG content - find the first generated page
|
||||
svg_pattern = core_package_root / f'temp_flashcards_{os.getpid()}_*.svg'
|
||||
import glob
|
||||
svg_files = glob.glob(str(svg_pattern))
|
||||
|
||||
if not svg_files:
|
||||
return json.dumps({
|
||||
'error': 'No SVG files were generated'
|
||||
})
|
||||
|
||||
# Read the first SVG file (page 1)
|
||||
svg_file = Path(svg_files[0])
|
||||
with open(svg_file, 'r', encoding='utf-8') as f:
|
||||
svg_content = f.read()
|
||||
|
||||
# Clean up all generated SVG files
|
||||
for svg_path in svg_files:
|
||||
Path(svg_path).unlink()
|
||||
|
||||
result_data = {
|
||||
'pdf': svg_content, # Keep field name for compatibility
|
||||
'count': len(numbers),
|
||||
'numbers': numbers[:100]
|
||||
}
|
||||
else:
|
||||
# Generate PDF (default)
|
||||
result = subprocess.run(
|
||||
['typst', 'compile', str(temp_typst), str(temp_pdf)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(core_package_root)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return json.dumps({
|
||||
'error': f'Typst compilation failed: {result.stderr}'
|
||||
})
|
||||
|
||||
# Read and encode PDF
|
||||
with open(temp_pdf, 'rb') as f:
|
||||
pdf_bytes = f.read()
|
||||
|
||||
result_data = {
|
||||
'pdf': base64.b64encode(pdf_bytes).decode('utf-8'),
|
||||
'count': len(numbers),
|
||||
'numbers': numbers[:100] # Limit preview
|
||||
}
|
||||
finally:
|
||||
# Clean up temp files
|
||||
for temp_file in [temp_typst, temp_pdf, temp_svg if output_format == 'svg' else None]:
|
||||
if temp_file and temp_file.exists():
|
||||
temp_file.unlink()
|
||||
|
||||
return json.dumps(result_data)
|
||||
|
||||
def generate_single_card_json(config_json):
|
||||
"""
|
||||
Generate a single card SVG from JSON config
|
||||
Specifically for preview - always returns front side (abacus)
|
||||
"""
|
||||
config = json.loads(config_json)
|
||||
|
||||
# Extract the single number
|
||||
number = config.get('number')
|
||||
if number is None:
|
||||
return json.dumps({'error': 'Missing number parameter'})
|
||||
|
||||
# Build Typst config optimized for preview display
|
||||
typst_config = {
|
||||
'bead_shape': config.get('beadShape', 'diamond'),
|
||||
'color_scheme': config.get('colorScheme', 'monochrome'),
|
||||
'color_palette': config.get('colorPalette', 'default'),
|
||||
'colored_numerals': config.get('coloredNumerals', False),
|
||||
'hide_inactive_beads': config.get('hideInactiveBeads', False),
|
||||
'show_empty_columns': config.get('showEmptyColumns', False),
|
||||
'columns': config.get('columns', 'auto'),
|
||||
'transparent': config.get('transparent', False),
|
||||
'card_width': '120pt', # Smaller card for larger abacus
|
||||
'card_height': '160pt', # Smaller card for larger abacus
|
||||
'font_size': config.get('fontSize', '48pt'),
|
||||
'font_family': config.get('fontFamily', 'DejaVu Sans'),
|
||||
'scale_factor': config.get('scaleFactor', 4.0), # Much larger scale for preview visibility
|
||||
}
|
||||
|
||||
# Generate in core package directory
|
||||
core_package_root = Path(__file__).parent.parent
|
||||
temp_typst = core_package_root / f'temp_single_{number}_{os.getpid()}.typ'
|
||||
temp_svg = core_package_root / f'temp_single_{number}_{os.getpid()}.svg'
|
||||
|
||||
try:
|
||||
# Create single card content directly with correct template path
|
||||
typst_content = f'''
|
||||
#import "templates/single-card.typ": generate-single-card
|
||||
|
||||
#generate-single-card(
|
||||
{number},
|
||||
side: "front",
|
||||
bead-shape: "{typst_config['bead_shape']}",
|
||||
color-scheme: "{typst_config['color_scheme']}",
|
||||
color-palette: "{typst_config['color_palette']}",
|
||||
colored-numerals: {str(typst_config['colored_numerals']).lower()},
|
||||
hide-inactive-beads: {str(typst_config['hide_inactive_beads']).lower()},
|
||||
show-empty-columns: {str(typst_config['show_empty_columns']).lower()},
|
||||
columns: {typst_config['columns']},
|
||||
transparent: {str(typst_config['transparent']).lower()},
|
||||
width: {typst_config['card_width']},
|
||||
height: {typst_config['card_height']},
|
||||
font-size: {typst_config['font_size']},
|
||||
font-family: "{typst_config['font_family']}",
|
||||
scale-factor: {typst_config['scale_factor']}
|
||||
)
|
||||
'''
|
||||
|
||||
with open(temp_typst, 'w') as f:
|
||||
f.write(typst_content)
|
||||
|
||||
# Generate SVG
|
||||
result = subprocess.run(
|
||||
['typst', 'compile', str(temp_typst), str(temp_svg), '--format', 'svg'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(core_package_root)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return json.dumps({
|
||||
'error': f'Typst SVG compilation failed: {result.stderr}'
|
||||
})
|
||||
|
||||
# Read SVG content
|
||||
if not temp_svg.exists():
|
||||
return json.dumps({
|
||||
'error': 'SVG file was not generated'
|
||||
})
|
||||
|
||||
with open(temp_svg, 'r', encoding='utf-8') as f:
|
||||
svg_content = f.read()
|
||||
|
||||
return json.dumps({
|
||||
'pdf': svg_content, # Keep field name for compatibility
|
||||
'count': 1,
|
||||
'numbers': [number]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({'error': f'Single card generation failed: {str(e)}'})
|
||||
finally:
|
||||
# Clean up temp files
|
||||
for temp_file in [temp_typst, temp_svg]:
|
||||
if temp_file and temp_file.exists():
|
||||
temp_file.unlink()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Read JSON from stdin, write JSON to stdout
|
||||
# This allows clean function-like communication
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
config = json.loads(line.strip())
|
||||
|
||||
# Check if this is a single-card generation request
|
||||
if config.get('mode') == 'single-card':
|
||||
result = generate_single_card_json(line.strip())
|
||||
else:
|
||||
result = generate_flashcards_json(line.strip())
|
||||
|
||||
print(result)
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
print(json.dumps({'error': str(e)}))
|
||||
sys.stdout.flush()
|
||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -119,9 +119,6 @@ importers:
|
||||
'@soroban/abacus-react':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/abacus-react
|
||||
'@soroban/client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core/client/typescript
|
||||
'@soroban/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core/client/node
|
||||
@@ -380,49 +377,8 @@ importers:
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
|
||||
|
||||
packages/core/client/browser:
|
||||
dependencies:
|
||||
'@myriaddreamin/typst-ts-renderer':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
'@myriaddreamin/typst-ts-web-compiler':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^5.0.0
|
||||
version: 5.4.20(@types/node@20.19.19)(terser@5.44.0)
|
||||
|
||||
packages/core/client/node:
|
||||
dependencies:
|
||||
python-shell:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
devDependencies:
|
||||
'@types/minimatch':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.19
|
||||
tsup:
|
||||
specifier: ^7.0.0
|
||||
version: 7.3.0(postcss@8.5.6)(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
|
||||
|
||||
packages/core/client/typescript:
|
||||
devDependencies:
|
||||
'@types/minimatch':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.19
|
||||
@@ -1957,12 +1913,6 @@ packages:
|
||||
'@types/react': '>=16'
|
||||
react: '>=16'
|
||||
|
||||
'@myriaddreamin/typst-ts-renderer@0.6.0':
|
||||
resolution: {integrity: sha512-56Mids4E5Ob6LeEeXDedvmsVnEWnLmc1qeUOeUSruL/zI3S9QXleF/c3Os1FXwJmLuCFbWTEIq8Quh2cXlnxKw==}
|
||||
|
||||
'@myriaddreamin/typst-ts-web-compiler@0.6.0':
|
||||
resolution: {integrity: sha512-P/eIJ5RnfElj0NYzn5PI296t/IwWtgqUyyTMi5Jm5X3V5kZfskkH+LI7mSQe8tEyxwgCvxbxvFe5adinA3K8Gg==}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -7773,10 +7723,6 @@ packages:
|
||||
python-bridge@1.1.0:
|
||||
resolution: {integrity: sha512-qjQ0QB8p9cn/XDeILQH0aP307hV58lrmv0Opjyub68Um7FHdF+ZXlTqyxNkKaXOFk2QSkScoPWwn7U9GGnrkeQ==}
|
||||
|
||||
python-shell@5.0.0:
|
||||
resolution: {integrity: sha512-RUOOOjHLhgR1MIQrCtnEqz/HJ1RMZBIN+REnpSUrfft2bXqXy69fwJASVziWExfFXsR1bCY0TznnHooNsCo0/w==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
qs@6.13.0:
|
||||
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -10814,10 +10760,6 @@ snapshots:
|
||||
'@types/react': 18.3.26
|
||||
react: 18.3.1
|
||||
|
||||
'@myriaddreamin/typst-ts-renderer@0.6.0': {}
|
||||
|
||||
'@myriaddreamin/typst-ts-web-compiler@0.6.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.5.0
|
||||
@@ -17642,8 +17584,6 @@ snapshots:
|
||||
dependencies:
|
||||
bluebird: 3.7.2
|
||||
|
||||
python-shell@5.0.0: {}
|
||||
|
||||
qs@6.13.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
Reference in New Issue
Block a user