Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
297927401c | ||
|
|
b45139b588 | ||
|
|
a57ebdf142 | ||
|
|
98a3a2573d | ||
|
|
0fd680396c | ||
|
|
4afa171af2 | ||
|
|
f37733bff6 | ||
|
|
2ffeade437 | ||
|
|
d8b5201af9 | ||
|
|
554cc4063b | ||
|
|
6bb7016eea | ||
|
|
4124f1cc08 | ||
|
|
ee39241e3c | ||
|
|
f07b96d26e | ||
|
|
a9a6cefafc | ||
|
|
710e93c997 | ||
|
|
b419e5e3ad | ||
|
|
245ed8a625 | ||
|
|
2b68ddc732 | ||
|
|
1c55f3630c |
62
CHANGELOG.md
62
CHANGELOG.md
@@ -1,3 +1,65 @@
|
||||
## [3.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.3...v3.14.4) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **memory-quiz:** prevent input lag during rapid typing in room mode ([b45139b](https://github.com/antialias/soroban-abacus-flashcards/commit/b45139b588d0ab6df4d6c1003c1b65b634e2b041))
|
||||
|
||||
## [3.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.2...v3.14.3) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** delete old session when room game changes ([98a3a25](https://github.com/antialias/soroban-abacus-flashcards/commit/98a3a2573db51899c41ba02796895d676c4e16ef))
|
||||
|
||||
## [3.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.1...v3.14.2) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **room:** update GAME_TYPE_TO_NAME mapping for memory-quiz ([4afa171](https://github.com/antialias/soroban-abacus-flashcards/commit/4afa171af212902120599b3d68f58cfbdf7820b0))
|
||||
|
||||
## [3.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.0...v3.14.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve Memory Quiz room-based multiplayer validation issues ([2ffeade](https://github.com/antialias/soroban-abacus-flashcards/commit/2ffeade43710b5f3fff9991cc84763bbdbf97010))
|
||||
|
||||
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** add Change Game functionality for room hosts ([ee39241](https://github.com/antialias/soroban-abacus-flashcards/commit/ee39241e3c9e04202592497d9987eafcb89c00c9))
|
||||
* **arcade:** add game selection screen with navigation to room page ([4124f1c](https://github.com/antialias/soroban-abacus-flashcards/commit/4124f1cc081f5cb9d6f450f3c2e0cca8a247deba))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **player-config:** correct label positioning in player settings dialog ([554cc40](https://github.com/antialias/soroban-abacus-flashcards/commit/554cc4063bc756c9c9cd1adf0c1964d3f2f6151b))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* implement in-room game selection UI ([f07b96d](https://github.com/antialias/soroban-abacus-flashcards/commit/f07b96d26eb9f63f3ee55f721139c37ccc34c3df))
|
||||
* make game_name nullable to support in-room game selection ([a9a6cef](https://github.com/antialias/soroban-abacus-flashcards/commit/a9a6cefafcaf7340902328ef1cb02eb3fdd3aa84))
|
||||
* **nav:** rename emphasizeGameContext to emphasizePlayerSelection ([6bb7016](https://github.com/antialias/soroban-abacus-flashcards/commit/6bb7016eea1e8ca40204a921db4a8b8fb9a06f73))
|
||||
|
||||
## [3.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.6...v3.13.7) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **toast:** scope animations to prevent affecting other UI elements ([245ed8a](https://github.com/antialias/soroban-abacus-flashcards/commit/245ed8a625ba848f8cd79d51bfd88600cd77f0b9))
|
||||
|
||||
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **nav:** navigate to /arcade/room (not /arcade/rooms/{id}) ([1c55f36](https://github.com/antialias/soroban-abacus-flashcards/commit/1c55f3630cb5f07b685555e41baa5a49314f15a3))
|
||||
|
||||
## [3.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.4...v3.13.5) (2025-10-14)
|
||||
|
||||
|
||||
|
||||
@@ -89,3 +89,22 @@ npm run check # Biome check (format + lint + organize imports)
|
||||
---
|
||||
|
||||
**Remember: Always run `npm run pre-commit` before creating commits.**
|
||||
|
||||
## Known Issues
|
||||
|
||||
### @soroban/abacus-react TypeScript Module Resolution
|
||||
|
||||
**Issue:** TypeScript reports that `AbacusReact`, `useAbacusConfig`, and other exports do not exist from the `@soroban/abacus-react` package, even though:
|
||||
- The package builds successfully
|
||||
- The exports are correctly defined in `dist/index.d.ts`
|
||||
- The imports work at runtime
|
||||
- 20+ files across the codebase use these same imports without issue
|
||||
|
||||
**Impact:** `npm run type-check` will report errors for any files importing from `@soroban/abacus-react`.
|
||||
|
||||
**Workaround:** This is a known pre-existing issue. When running pre-commit checks, TypeScript errors related to `@soroban/abacus-react` imports can be ignored. Focus on:
|
||||
- New TypeScript errors in your changed files (excluding @soroban/abacus-react imports)
|
||||
- Format checks
|
||||
- Lint checks
|
||||
|
||||
**Status:** Known issue, does not block development or deployment.
|
||||
|
||||
@@ -60,7 +60,16 @@
|
||||
"Bash(npx @biomejs/biome format:*)",
|
||||
"Bash(npx drizzle-kit generate:*)",
|
||||
"Bash(ssh nas.home.network \"docker ps | grep -E ''soroban|abaci|web''\")",
|
||||
"Bash(ssh:*)"
|
||||
"Bash(ssh:*)",
|
||||
"Bash(printf \"\\n\\n\")",
|
||||
"Bash(timeout 10 npx drizzle-kit generate:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(killall:*)",
|
||||
"Bash(echo:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
0
apps/web/data/db.sqlite
Normal file
0
apps/web/data/db.sqlite
Normal file
42
apps/web/drizzle/0010_make_game_name_nullable.sql
Normal file
42
apps/web/drizzle/0010_make_game_name_nullable.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Make game_name and game_config nullable to support game selection in room
|
||||
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
||||
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
|
||||
-- Create temporary table with correct schema
|
||||
CREATE TABLE `arcade_rooms_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`code` text(6) NOT NULL,
|
||||
`name` text(50),
|
||||
`created_by` text NOT NULL,
|
||||
`creator_name` text(50) NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`last_activity` integer NOT NULL,
|
||||
`ttl_minutes` integer DEFAULT 60 NOT NULL,
|
||||
`access_mode` text DEFAULT 'open' NOT NULL,
|
||||
`password` text(255),
|
||||
`display_password` text(100),
|
||||
`game_name` text,
|
||||
`game_config` text,
|
||||
`status` text DEFAULT 'lobby' NOT NULL,
|
||||
`current_session_id` text,
|
||||
`total_games_played` integer DEFAULT 0 NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- Copy all data
|
||||
INSERT INTO `arcade_rooms_new`
|
||||
SELECT `id`, `code`, `name`, `created_by`, `creator_name`, `created_at`,
|
||||
`last_activity`, `ttl_minutes`, `access_mode`, `password`, `display_password`,
|
||||
`game_name`, `game_config`, `status`, `current_session_id`, `total_games_played`
|
||||
FROM `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Rename new table
|
||||
ALTER TABLE `arcade_rooms_new` RENAME TO `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Recreate index
|
||||
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1760600000000,
|
||||
"tag": "0009_add_display_password",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1760700000000,
|
||||
"tag": "0010_make_game_name_nullable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ type RouteContext = {
|
||||
* Body:
|
||||
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
|
||||
* - password?: string (plain text, will be hashed)
|
||||
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
|
||||
* - gameConfig?: object (game-specific settings)
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
@@ -58,6 +60,14 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
)
|
||||
}
|
||||
|
||||
// Validate gameName if provided
|
||||
if (body.gameName !== undefined && body.gameName !== null) {
|
||||
const validGames = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: Record<string, any> = {}
|
||||
|
||||
@@ -77,6 +87,23 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update game selection if provided
|
||||
if (body.gameName !== undefined) {
|
||||
updateData.gameName = body.gameName
|
||||
}
|
||||
|
||||
// Update game config if provided
|
||||
if (body.gameConfig !== undefined) {
|
||||
updateData.gameConfig = body.gameConfig
|
||||
}
|
||||
|
||||
// If game is being changed (or cleared), delete the existing arcade session
|
||||
// This ensures a fresh session will be created with the new game settings
|
||||
if (body.gameName !== undefined) {
|
||||
console.log(`[Settings API] Deleting existing arcade session for room ${roomId}`)
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId))
|
||||
}
|
||||
|
||||
// Update room settings
|
||||
const [updatedRoom] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
|
||||
@@ -70,15 +70,12 @@ export async function POST(req: NextRequest) {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields (name is optional, gameName is required)
|
||||
if (!body.gameName) {
|
||||
return NextResponse.json({ error: 'Missing required field: gameName' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate game name
|
||||
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
// Validate game name if provided (gameName is now optional)
|
||||
if (body.gameName) {
|
||||
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate name length (if provided)
|
||||
@@ -120,8 +117,8 @@ export async function POST(req: NextRequest) {
|
||||
name: roomName,
|
||||
createdBy: viewerId,
|
||||
creatorName: displayName,
|
||||
gameName: body.gameName,
|
||||
gameConfig: body.gameConfig || {},
|
||||
gameName: body.gameName || null,
|
||||
gameConfig: body.gameConfig || null,
|
||||
ttlMinutes: body.ttlMinutes,
|
||||
accessMode: body.accessMode,
|
||||
password: body.password,
|
||||
|
||||
@@ -48,9 +48,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
|
||||
it('should return 403 when trying to change isActive with active arcade session', async () => {
|
||||
// Create an arcade room first
|
||||
const [room] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: 'TEST01',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
@@ -117,9 +134,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
|
||||
it('should allow non-isActive changes even with active arcade session', async () => {
|
||||
// Create an arcade room first
|
||||
const [room] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: 'TEST02',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
@@ -164,9 +198,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
|
||||
it('should allow isActive change after arcade session ends', async () => {
|
||||
// Create an arcade room first
|
||||
const [room] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: 'TEST03',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
@@ -179,7 +230,7 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
|
||||
// End the session
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, room.id))
|
||||
|
||||
// Mock request to change isActive
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
@@ -212,9 +263,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an arcade room first
|
||||
const [room] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: 'TEST04',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create arcade session
|
||||
const now2 = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
|
||||
@@ -109,7 +109,10 @@ export default function RoomBrowserPage() {
|
||||
}
|
||||
|
||||
if (room.accessMode === 'restricted') {
|
||||
showInfo('Invitation Only', 'This room is invitation-only. Please ask the host for an invitation.')
|
||||
showInfo(
|
||||
'Invitation Only',
|
||||
'This room is invitation-only. Please ask the host for an invitation.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function MemoryPairsGame() {
|
||||
<PageWithNav
|
||||
navTitle={navTitle}
|
||||
navEmoji={navEmoji}
|
||||
emphasizeGameContext={state.gamePhase === 'setup'}
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
|
||||
197
apps/web/src/app/arcade/memory-quiz/components/CardGrid.tsx
Normal file
197
apps/web/src/app/arcade/memory-quiz/components/CardGrid.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import type { SorobanQuizState } from '../types'
|
||||
|
||||
interface CardGridProps {
|
||||
state: SorobanQuizState
|
||||
}
|
||||
|
||||
export function CardGrid({ state }: CardGridProps) {
|
||||
if (state.quizCards.length === 0) return null
|
||||
|
||||
// Calculate optimal grid layout based on number of cards
|
||||
const cardCount = state.quizCards.length
|
||||
|
||||
// Define static grid classes that Panda can generate
|
||||
const getGridClass = (count: number) => {
|
||||
if (count <= 2) return 'repeat(2, 1fr)'
|
||||
if (count <= 4) return 'repeat(2, 1fr)'
|
||||
if (count <= 6) return 'repeat(3, 1fr)'
|
||||
if (count <= 9) return 'repeat(3, 1fr)'
|
||||
if (count <= 12) return 'repeat(4, 1fr)'
|
||||
return 'repeat(5, 1fr)'
|
||||
}
|
||||
|
||||
const getCardSize = (count: number) => {
|
||||
if (count <= 2) return { minSize: '180px', cardHeight: '160px' }
|
||||
if (count <= 4) return { minSize: '160px', cardHeight: '150px' }
|
||||
if (count <= 6) return { minSize: '140px', cardHeight: '140px' }
|
||||
if (count <= 9) return { minSize: '120px', cardHeight: '130px' }
|
||||
if (count <= 12) return { minSize: '110px', cardHeight: '120px' }
|
||||
return { minSize: '100px', cardHeight: '110px' }
|
||||
}
|
||||
|
||||
const gridClass = getGridClass(cardCount)
|
||||
const cardSize = getCardSize(cardCount)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
background: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<h4
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: '#374151',
|
||||
marginBottom: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
Cards you saw ({cardCount}):
|
||||
</h4>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '8px',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
width: 'fit-content',
|
||||
gridTemplateColumns: gridClass,
|
||||
}}
|
||||
>
|
||||
{state.quizCards.map((card, index) => {
|
||||
const isRevealed = state.foundNumbers.includes(card.number)
|
||||
return (
|
||||
<div
|
||||
key={`card-${index}-${card.number}`}
|
||||
style={{
|
||||
perspective: '1000px',
|
||||
maxWidth: '200px',
|
||||
height: cardSize.cardHeight,
|
||||
minWidth: cardSize.minSize,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.8s',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isRevealed ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
}}
|
||||
>
|
||||
{/* Card back (hidden state) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
|
||||
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
|
||||
color: 'white',
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
border: '2px solid #5f3dc4',
|
||||
}}
|
||||
>
|
||||
<div style={{ opacity: 0.8 }}>?</div>
|
||||
</div>
|
||||
|
||||
{/* Card front (revealed state) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
|
||||
background: 'white',
|
||||
border: '2px solid #28a745',
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={card.number}
|
||||
columns="auto"
|
||||
beadShape="diamond"
|
||||
colorScheme="place-value"
|
||||
hideInactiveBeads={false}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary row for large numbers of cards */}
|
||||
{cardCount > 8 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '6px 8px',
|
||||
background: '#eff6ff',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #bfdbfe',
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: '#1d4ed8',
|
||||
}}
|
||||
>
|
||||
<strong>{state.foundNumbers.length}</strong> of <strong>{cardCount}</strong> cards found
|
||||
{state.foundNumbers.length > 0 && (
|
||||
<span style={{ marginLeft: '6px', fontWeight: 'normal' }}>
|
||||
({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
apps/web/src/app/arcade/memory-quiz/components/DisplayPhase.tsx
Normal file
206
apps/web/src/app/arcade/memory-quiz/components/DisplayPhase.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import type { QuizCard } from '../types'
|
||||
|
||||
// Calculate maximum columns needed for a set of numbers
|
||||
function calculateMaxColumns(numbers: number[]): number {
|
||||
if (numbers.length === 0) return 1
|
||||
const maxNumber = Math.max(...numbers)
|
||||
if (maxNumber === 0) return 1
|
||||
return Math.floor(Math.log10(maxNumber)) + 1
|
||||
}
|
||||
|
||||
export function DisplayPhase() {
|
||||
const { state, nextCard, showInputPhase, resetGame } = useMemoryQuiz()
|
||||
const [currentCard, setCurrentCard] = useState<QuizCard | null>(null)
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
const isDisplayPhaseActive = state.currentCardIndex < state.quizCards.length
|
||||
const isProcessingRef = useRef(false)
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
// Calculate maximum columns needed for this quiz set
|
||||
const maxColumns = useMemo(() => {
|
||||
const allNumbers = state.quizCards.map((card) => card.number)
|
||||
return calculateMaxColumns(allNumbers)
|
||||
}, [state.quizCards])
|
||||
|
||||
// Calculate adaptive animation duration
|
||||
const flashDuration = useMemo(() => {
|
||||
const displayTimeMs = state.displayTime * 1000
|
||||
return Math.min(Math.max(displayTimeMs * 0.3, 150), 600) / 1000 // Convert to seconds for CSS
|
||||
}, [state.displayTime])
|
||||
|
||||
const progressPercentage = (state.currentCardIndex / state.quizCards.length) * 100
|
||||
|
||||
useEffect(() => {
|
||||
if (state.currentCardIndex >= state.quizCards.length) {
|
||||
showInputPhase?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent multiple concurrent executions
|
||||
if (isProcessingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const showNextCard = async () => {
|
||||
isProcessingRef.current = true
|
||||
const card = state.quizCards[state.currentCardIndex]
|
||||
console.log(
|
||||
`DisplayPhase: Showing card ${state.currentCardIndex + 1}/${state.quizCards.length}, number: ${card.number}`
|
||||
)
|
||||
|
||||
// Calculate adaptive timing based on display speed
|
||||
const displayTimeMs = state.displayTime * 1000
|
||||
const flashDuration = Math.min(Math.max(displayTimeMs * 0.3, 150), 600) // 30% of display time, between 150ms-600ms
|
||||
const transitionPause = Math.min(Math.max(displayTimeMs * 0.1, 50), 200) // 10% of display time, between 50ms-200ms
|
||||
|
||||
// Trigger adaptive transition effect
|
||||
setIsTransitioning(true)
|
||||
setCurrentCard(card)
|
||||
|
||||
// Reset transition effect with adaptive duration
|
||||
setTimeout(() => setIsTransitioning(false), flashDuration)
|
||||
|
||||
console.log(
|
||||
`DisplayPhase: Card ${state.currentCardIndex + 1} now visible (flash: ${flashDuration}ms, pause: ${transitionPause}ms)`
|
||||
)
|
||||
|
||||
// Display card for specified time with adaptive transition pause
|
||||
await new Promise((resolve) => setTimeout(resolve, displayTimeMs - transitionPause))
|
||||
|
||||
// Don't hide the abacus - just advance to next card for smooth transition
|
||||
console.log(`DisplayPhase: Card ${state.currentCardIndex + 1} transitioning to next`)
|
||||
await new Promise((resolve) => setTimeout(resolve, transitionPause)) // Adaptive pause for visual transition
|
||||
|
||||
isProcessingRef.current = false
|
||||
nextCard?.()
|
||||
}
|
||||
|
||||
showNextCard()
|
||||
}, [
|
||||
state.currentCardIndex,
|
||||
state.displayTime,
|
||||
state.quizCards.length,
|
||||
nextCard,
|
||||
showInputPhase,
|
||||
state.quizCards[state.currentCardIndex],
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
boxSizing: 'border-box',
|
||||
height: '100%',
|
||||
animation: isTransitioning ? `subtlePageFlash ${flashDuration}s ease-out` : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #28a745, #20c997)',
|
||||
borderRadius: '4px',
|
||||
width: `${progressPercentage}%`,
|
||||
transition: 'width 0.5s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
}}
|
||||
>
|
||||
Card {state.currentCardIndex + 1} of {state.quizCards.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
style={{
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
onClick={() => resetGame?.()}
|
||||
>
|
||||
End Quiz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Persistent abacus container - stays mounted during entire memorize phase */}
|
||||
<div
|
||||
style={{
|
||||
width: 'min(90vw, 800px)',
|
||||
height: 'min(70vh, 500px)',
|
||||
display: isDisplayPhaseActive ? 'flex' : 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto',
|
||||
transition: 'opacity 0.3s ease',
|
||||
overflow: 'visible',
|
||||
padding: '20px 12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
{/* Persistent abacus with smooth bead animations and dynamically calculated columns */}
|
||||
<AbacusReact
|
||||
value={currentCard?.number || 0}
|
||||
columns={maxColumns}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={5.5}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
696
apps/web/src/app/arcade/memory-quiz/components/InputPhase.tsx
Normal file
696
apps/web/src/app/arcade/memory-quiz/components/InputPhase.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { isPrefix } from '@/lib/memory-quiz-utils'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import { CardGrid } from './CardGrid'
|
||||
|
||||
export function InputPhase() {
|
||||
const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = useMemoryQuiz()
|
||||
const _containerRef = useRef<HTMLDivElement>(null)
|
||||
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>(
|
||||
'neutral'
|
||||
)
|
||||
|
||||
// Use a ref to track the current input to avoid stale reads during rapid typing
|
||||
// This prevents the "type 8 times" issue where state.currentInput hasn't updated yet
|
||||
const currentInputRef = useRef(state.currentInput)
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => {
|
||||
currentInputRef.current = state.currentInput
|
||||
}, [state.currentInput])
|
||||
|
||||
// Use keyboard state from parent state instead of local state
|
||||
const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state
|
||||
|
||||
// Debug: Log state changes and detect what's causing re-renders
|
||||
useEffect(() => {
|
||||
console.log('🔍 Keyboard state changed:', {
|
||||
hasPhysicalKeyboard,
|
||||
testingMode,
|
||||
showOnScreenKeyboard,
|
||||
})
|
||||
console.trace('🔍 State change trace:')
|
||||
}, [hasPhysicalKeyboard, testingMode, showOnScreenKeyboard])
|
||||
|
||||
// Debug: Monitor for unexpected state resets
|
||||
useEffect(() => {
|
||||
if (showOnScreenKeyboard) {
|
||||
const timer = setTimeout(() => {
|
||||
if (!showOnScreenKeyboard) {
|
||||
console.error('🚨 Keyboard was unexpectedly hidden!')
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showOnScreenKeyboard])
|
||||
|
||||
// Detect physical keyboard availability (disabled when testing mode is active)
|
||||
useEffect(() => {
|
||||
// Skip keyboard detection entirely when testing mode is enabled
|
||||
if (testingMode) {
|
||||
console.log('🧪 Testing mode enabled - skipping keyboard detection')
|
||||
return
|
||||
}
|
||||
|
||||
let detectionTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const detectKeyboard = () => {
|
||||
// Method 1: Check if device supports keyboard via media queries
|
||||
const hasKeyboardSupport =
|
||||
window.matchMedia('(pointer: fine)').matches && window.matchMedia('(hover: hover)').matches
|
||||
|
||||
// Method 2: Check if device is likely touch-only
|
||||
const isTouchDevice =
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
|
||||
// Method 3: Check viewport characteristics for mobile devices
|
||||
const isMobileViewport = window.innerWidth <= 768 && window.innerHeight <= 1024
|
||||
|
||||
// Combined heuristic: assume no physical keyboard if:
|
||||
// - It's a touch device AND has mobile viewport AND lacks precise pointer
|
||||
const likelyNoKeyboard = isTouchDevice && isMobileViewport && !hasKeyboardSupport
|
||||
|
||||
console.log('⌨️ Keyboard detection result:', !likelyNoKeyboard)
|
||||
dispatch({
|
||||
type: 'SET_PHYSICAL_KEYBOARD',
|
||||
hasKeyboard: !likelyNoKeyboard,
|
||||
})
|
||||
}
|
||||
|
||||
// Test for actual keyboard input within 3 seconds
|
||||
let keyboardDetected = false
|
||||
const handleFirstKeyPress = (e: KeyboardEvent) => {
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
console.log('⌨️ Physical keyboard detected via keypress')
|
||||
keyboardDetected = true
|
||||
dispatch({ type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: true })
|
||||
document.removeEventListener('keypress', handleFirstKeyPress)
|
||||
if (detectionTimer) clearTimeout(detectionTimer)
|
||||
}
|
||||
}
|
||||
|
||||
// Start detection
|
||||
document.addEventListener('keypress', handleFirstKeyPress)
|
||||
|
||||
// Fallback to heuristic detection after 3 seconds
|
||||
detectionTimer = setTimeout(() => {
|
||||
if (!keyboardDetected) {
|
||||
console.log('⌨️ Using fallback keyboard detection')
|
||||
detectKeyboard()
|
||||
}
|
||||
document.removeEventListener('keypress', handleFirstKeyPress)
|
||||
}, 3000)
|
||||
|
||||
// Initial heuristic detection (but don't commit to it yet)
|
||||
const initialDetection = setTimeout(detectKeyboard, 100)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keypress', handleFirstKeyPress)
|
||||
if (detectionTimer) clearTimeout(detectionTimer)
|
||||
clearTimeout(initialDetection)
|
||||
}
|
||||
}, [testingMode, dispatch])
|
||||
|
||||
const acceptCorrectNumber = useCallback(
|
||||
(number: number) => {
|
||||
acceptNumber?.(number)
|
||||
currentInputRef.current = '' // Clear ref immediately
|
||||
setInput?.('')
|
||||
setDisplayFeedback('correct')
|
||||
|
||||
setTimeout(() => setDisplayFeedback('neutral'), 500)
|
||||
|
||||
// Auto-finish if all found
|
||||
if (state.foundNumbers.length + 1 === state.correctAnswers.length) {
|
||||
setTimeout(() => showResults?.(), 1000)
|
||||
}
|
||||
},
|
||||
[acceptNumber, setInput, showResults, state.foundNumbers.length, state.correctAnswers.length]
|
||||
)
|
||||
|
||||
const handleIncorrectGuess = useCallback(() => {
|
||||
const wrongNumber = parseInt(currentInputRef.current, 10)
|
||||
if (!Number.isNaN(wrongNumber)) {
|
||||
dispatch({ type: 'ADD_WRONG_GUESS_ANIMATION', number: wrongNumber })
|
||||
// Clear wrong guess animations after explosion
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'CLEAR_WRONG_GUESS_ANIMATIONS' })
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
rejectNumber?.()
|
||||
currentInputRef.current = '' // Clear ref immediately
|
||||
setInput?.('')
|
||||
setDisplayFeedback('incorrect')
|
||||
|
||||
setTimeout(() => setDisplayFeedback('neutral'), 500)
|
||||
|
||||
// Auto-finish if out of guesses
|
||||
if (state.guessesRemaining - 1 === 0) {
|
||||
setTimeout(() => showResults?.(), 1000)
|
||||
}
|
||||
}, [dispatch, rejectNumber, setInput, showResults, state.guessesRemaining])
|
||||
|
||||
// Simple keyboard event handlers that will be defined after callbacks
|
||||
const handleKeyboardInput = useCallback(
|
||||
(key: string) => {
|
||||
// Handle number input
|
||||
if (/^[0-9]$/.test(key)) {
|
||||
// Only handle if input phase is active and guesses remain
|
||||
if (state.guessesRemaining === 0) return
|
||||
|
||||
// Use ref for immediate value, update ref right away to prevent stale reads
|
||||
const newInput = currentInputRef.current + key
|
||||
currentInputRef.current = newInput // Update ref immediately for next keypress
|
||||
setInput?.(newInput)
|
||||
|
||||
// Clear any existing timeout
|
||||
if (state.prefixAcceptanceTimeout) {
|
||||
clearTimeout(state.prefixAcceptanceTimeout)
|
||||
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout: null })
|
||||
}
|
||||
|
||||
setDisplayFeedback('neutral')
|
||||
|
||||
const number = parseInt(newInput, 10)
|
||||
if (Number.isNaN(number)) return
|
||||
|
||||
// Check if correct and not already found
|
||||
if (state.correctAnswers.includes(number) && !state.foundNumbers.includes(number)) {
|
||||
if (!isPrefix(newInput, state.correctAnswers, state.foundNumbers)) {
|
||||
acceptCorrectNumber(number)
|
||||
} else {
|
||||
const timeout = setTimeout(() => {
|
||||
acceptCorrectNumber(number)
|
||||
}, 500)
|
||||
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout })
|
||||
}
|
||||
} else {
|
||||
// Check if this input could be a valid prefix or complete number
|
||||
const couldBePrefix = state.correctAnswers.some((n) => n.toString().startsWith(newInput))
|
||||
const isCompleteWrongNumber = !state.correctAnswers.includes(number) && !couldBePrefix
|
||||
|
||||
// Trigger explosion if:
|
||||
// 1. It's a complete wrong number (length >= 2 or can't be a prefix)
|
||||
// 2. It's a single digit that can't possibly be a prefix of any target
|
||||
if ((newInput.length >= 2 || isCompleteWrongNumber) && state.guessesRemaining > 0) {
|
||||
handleIncorrectGuess()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
state.currentInput,
|
||||
state.prefixAcceptanceTimeout,
|
||||
state.correctAnswers,
|
||||
state.foundNumbers,
|
||||
state.guessesRemaining,
|
||||
dispatch,
|
||||
setInput,
|
||||
acceptCorrectNumber,
|
||||
handleIncorrectGuess,
|
||||
]
|
||||
)
|
||||
|
||||
const handleKeyboardBackspace = useCallback(() => {
|
||||
if (currentInputRef.current.length > 0) {
|
||||
const newInput = currentInputRef.current.slice(0, -1)
|
||||
currentInputRef.current = newInput // Update ref immediately
|
||||
setInput?.(newInput)
|
||||
|
||||
// Clear any existing timeout
|
||||
if (state.prefixAcceptanceTimeout) {
|
||||
clearTimeout(state.prefixAcceptanceTimeout)
|
||||
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout: null })
|
||||
}
|
||||
|
||||
setDisplayFeedback('neutral')
|
||||
}
|
||||
}, [state.prefixAcceptanceTimeout, dispatch, setInput])
|
||||
|
||||
// Set up global keyboard listeners
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle backspace/delete on keydown to prevent repetition
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault()
|
||||
handleKeyboardBackspace()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPressEvent = (e: KeyboardEvent) => {
|
||||
// Handle number input
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
handleKeyboardInput(e.key)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keypress', handleKeyPressEvent)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keypress', handleKeyPressEvent)
|
||||
}
|
||||
}, [handleKeyboardInput, handleKeyboardBackspace])
|
||||
|
||||
const hasFoundSome = state.foundNumbers.length > 0
|
||||
const hasFoundAll = state.foundNumbers.length === state.correctAnswers.length
|
||||
const outOfGuesses = state.guessesRemaining === 0
|
||||
const showFinishButtons = hasFoundAll || outOfGuesses || hasFoundSome
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
paddingBottom:
|
||||
(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0
|
||||
? '100px'
|
||||
: '12px', // Add space for keyboard
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
Enter the Numbers You Remember
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
background: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: '80px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Cards shown:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
{state.quizCards.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: '80px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Guesses left:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
{state.guessesRemaining}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: '80px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Found:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
{state.foundNumbers.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
margin: '16px 0',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{state.guessesRemaining === 0
|
||||
? '🚫 No more guesses available'
|
||||
: '⌨️ Type the numbers you remember'}
|
||||
</div>
|
||||
|
||||
{/* Testing control - remove in production */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#9ca3af',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={testingMode}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: 'SET_TESTING_MODE',
|
||||
enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Test on-screen keyboard (for demo)
|
||||
</label>
|
||||
<div style={{ fontSize: '9px', opacity: 0.7 }}>
|
||||
Keyboard detected:{' '}
|
||||
{hasPhysicalKeyboard === null ? 'detecting...' : hasPhysicalKeyboard ? 'yes' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '50px',
|
||||
padding: '12px 16px',
|
||||
fontSize: '22px',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
color: state.guessesRemaining === 0 ? '#6b7280' : '#1f2937',
|
||||
letterSpacing: '1px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
background:
|
||||
displayFeedback === 'correct'
|
||||
? 'linear-gradient(45deg, #d4edda, #c3e6cb)'
|
||||
: displayFeedback === 'incorrect'
|
||||
? 'linear-gradient(45deg, #f8d7da, #f1b0b7)'
|
||||
: state.guessesRemaining === 0
|
||||
? '#e5e7eb'
|
||||
: 'linear-gradient(135deg, #f0f8ff, #e6f3ff)',
|
||||
borderRadius: '12px',
|
||||
position: 'relative',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
displayFeedback === 'correct'
|
||||
? '#28a745'
|
||||
: displayFeedback === 'incorrect'
|
||||
? '#dc3545'
|
||||
: state.guessesRemaining === 0
|
||||
? '#9ca3af'
|
||||
: '#3b82f6',
|
||||
boxShadow:
|
||||
displayFeedback === 'correct'
|
||||
? '0 4px 12px rgba(40, 167, 69, 0.2)'
|
||||
: displayFeedback === 'incorrect'
|
||||
? '0 4px 12px rgba(220, 53, 69, 0.2)'
|
||||
: '0 4px 12px rgba(59, 130, 246, 0.15)',
|
||||
cursor: state.guessesRemaining === 0 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: 1, position: 'relative' }}>
|
||||
{state.guessesRemaining === 0
|
||||
? '🔒 Game Over'
|
||||
: state.currentInput || (
|
||||
<span
|
||||
style={{
|
||||
color: '#74c0fc',
|
||||
opacity: 0.8,
|
||||
fontStyle: 'normal',
|
||||
fontSize: '20px',
|
||||
}}
|
||||
>
|
||||
💭 Think & Type
|
||||
</span>
|
||||
)}
|
||||
{state.currentInput && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '2px',
|
||||
height: '20px',
|
||||
background: '#3b82f6',
|
||||
animation: 'blink 1s infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual card grid showing cards the user was shown */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
minHeight: '0',
|
||||
}}
|
||||
>
|
||||
<CardGrid state={state} />
|
||||
</div>
|
||||
|
||||
{/* Wrong guess explosion animations */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{state.wrongGuessAnimations.map((animation) => (
|
||||
<div
|
||||
key={animation.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
color: '#ef4444',
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
animation: 'explode 1.5s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{animation.number}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Simple fixed keyboard bar - appears when needed, no hiding of game elements */}
|
||||
{(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
borderTop: '2px solid #3b82f6',
|
||||
padding: '12px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((digit) => (
|
||||
<button
|
||||
key={digit}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
background: 'white',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
cursor: 'pointer',
|
||||
minWidth: '50px',
|
||||
minHeight: '50px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)'
|
||||
e.currentTarget.style.background = '#f3f4f6'
|
||||
e.currentTarget.style.borderColor = '#3b82f6'
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onClick={() => handleKeyboardInput(digit.toString())}
|
||||
>
|
||||
{digit}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #dc2626',
|
||||
borderRadius: '8px',
|
||||
background: state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: state.currentInput.length > 0 ? '#dc2626' : '#9ca3af',
|
||||
cursor: state.currentInput.length > 0 ? 'pointer' : 'not-allowed',
|
||||
minWidth: '70px',
|
||||
minHeight: '50px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
disabled={state.currentInput.length === 0}
|
||||
onClick={handleKeyboardBackspace}
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFinishButtons && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
onClick={() => showResults?.()}
|
||||
>
|
||||
{hasFoundAll ? 'Finish Quiz' : 'Show Results'}
|
||||
</button>
|
||||
{hasFoundSome && !hasFoundAll && !outOfGuesses && (
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
onClick={() => showResults?.()}
|
||||
>
|
||||
Can't Remember More
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import { DisplayPhase } from './DisplayPhase'
|
||||
import { InputPhase } from './InputPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
// CSS animations that need to be global
|
||||
const globalAnimations = `
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5); }
|
||||
100% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
|
||||
}
|
||||
|
||||
@keyframes subtlePageFlash {
|
||||
0% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
|
||||
50% { background: linear-gradient(to bottom right, #dcfce7, #d1fae5); }
|
||||
100% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from { opacity: 0; transform: scale(0.8); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes explode {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(2) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export function MemoryQuizGame() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useMemoryQuiz()
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Memory Lightning"
|
||||
navEmoji="🧠"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
resetGame?.()
|
||||
}}
|
||||
>
|
||||
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
padding: '20px 8px',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
mb: '4',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href="/arcade"
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: 'gray.600',
|
||||
textDecoration: 'none',
|
||||
mb: '4',
|
||||
_hover: { color: 'gray.800' },
|
||||
})}
|
||||
>
|
||||
← Back to Champion Arena
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
shadow: 'xl',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '100%',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'display' && <DisplayPhase />}
|
||||
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import type { SorobanQuizState } from '../types'
|
||||
|
||||
interface ResultsCardGridProps {
|
||||
state: SorobanQuizState
|
||||
}
|
||||
|
||||
export function ResultsCardGrid({ state }: ResultsCardGridProps) {
|
||||
if (state.quizCards.length === 0) return null
|
||||
|
||||
// Calculate optimal grid layout based on number of cards (same as CardGrid)
|
||||
const cardCount = state.quizCards.length
|
||||
|
||||
// Define static grid classes that Panda can generate (same as CardGrid)
|
||||
const getGridClass = (count: number) => {
|
||||
if (count <= 2) return 'repeat(2, 1fr)'
|
||||
if (count <= 4) return 'repeat(2, 1fr)'
|
||||
if (count <= 6) return 'repeat(3, 1fr)'
|
||||
if (count <= 9) return 'repeat(3, 1fr)'
|
||||
if (count <= 12) return 'repeat(4, 1fr)'
|
||||
return 'repeat(5, 1fr)'
|
||||
}
|
||||
|
||||
const getCardSize = (count: number) => {
|
||||
if (count <= 2) return { minSize: '180px', cardHeight: '160px' }
|
||||
if (count <= 4) return { minSize: '160px', cardHeight: '150px' }
|
||||
if (count <= 6) return { minSize: '140px', cardHeight: '140px' }
|
||||
if (count <= 9) return { minSize: '120px', cardHeight: '130px' }
|
||||
if (count <= 12) return { minSize: '110px', cardHeight: '120px' }
|
||||
return { minSize: '100px', cardHeight: '110px' }
|
||||
}
|
||||
|
||||
const gridClass = getGridClass(cardCount)
|
||||
const cardSize = getCardSize(cardCount)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '8px',
|
||||
padding: '6px',
|
||||
justifyContent: 'center',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
gridTemplateColumns: gridClass,
|
||||
}}
|
||||
>
|
||||
{state.quizCards.map((card, index) => {
|
||||
const isRevealed = true // All cards revealed in results
|
||||
const wasFound = state.foundNumbers.includes(card.number)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${card.number}-${index}`}
|
||||
style={{
|
||||
perspective: '1000px',
|
||||
position: 'relative',
|
||||
aspectRatio: '3/4',
|
||||
height: cardSize.cardHeight,
|
||||
minWidth: cardSize.minSize,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.8s',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isRevealed ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
}}
|
||||
>
|
||||
{/* Card back (hidden state) - not visible in results */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
|
||||
color: 'white',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
border: '2px solid #5f3dc4',
|
||||
}}
|
||||
>
|
||||
<div style={{ opacity: 0.8 }}>?</div>
|
||||
</div>
|
||||
|
||||
{/* Card front (revealed state) with success/failure indicators */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: wasFound ? '#10b981' : '#ef4444',
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={card.number}
|
||||
columns="auto"
|
||||
beadShape="diamond"
|
||||
colorScheme="place-value"
|
||||
hideInactiveBeads={false}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right/Wrong indicator overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: wasFound ? '#10b981' : '#ef4444',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
>
|
||||
{wasFound ? '✓' : '✗'}
|
||||
</div>
|
||||
|
||||
{/* Number label overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '4px',
|
||||
left: '4px',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '3px',
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary row for large numbers of cards (same as CardGrid) */}
|
||||
{cardCount > 8 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '6px 8px',
|
||||
background: '#eff6ff',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #bfdbfe',
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: '#1d4ed8',
|
||||
}}
|
||||
>
|
||||
<strong>{state.foundNumbers.length}</strong> of <strong>{cardCount}</strong> cards found
|
||||
{state.foundNumbers.length > 0 && (
|
||||
<span style={{ marginLeft: '6px', fontWeight: 'normal' }}>
|
||||
({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
apps/web/src/app/arcade/memory-quiz/components/ResultsPhase.tsx
Normal file
193
apps/web/src/app/arcade/memory-quiz/components/ResultsPhase.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
|
||||
import { ResultsCardGrid } from './ResultsCardGrid'
|
||||
|
||||
// Generate quiz cards with difficulty-based number ranges
|
||||
const generateQuizCards = (
|
||||
count: number,
|
||||
difficulty: DifficultyLevel,
|
||||
appConfig: any
|
||||
): QuizCard[] => {
|
||||
const { min, max } = DIFFICULTY_LEVELS[difficulty].range
|
||||
|
||||
// Generate unique numbers - no duplicates allowed
|
||||
const numbers: number[] = []
|
||||
const maxAttempts = (max - min + 1) * 10 // Prevent infinite loops
|
||||
let attempts = 0
|
||||
|
||||
while (numbers.length < count && attempts < maxAttempts) {
|
||||
const newNumber = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
if (!numbers.includes(newNumber)) {
|
||||
numbers.push(newNumber)
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
|
||||
// If we couldn't generate enough unique numbers, fill with sequential numbers
|
||||
if (numbers.length < count) {
|
||||
for (let i = min; i <= max && numbers.length < count; i++) {
|
||||
if (!numbers.includes(i)) {
|
||||
numbers.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return numbers.map((number) => ({
|
||||
number,
|
||||
svgComponent: <div />, // Placeholder - not used in results phase
|
||||
element: null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, resetGame, startQuiz } = useMemoryQuiz()
|
||||
const appConfig = useAbacusConfig()
|
||||
const correct = state.foundNumbers.length
|
||||
const total = state.correctAnswers.length
|
||||
const percentage = Math.round((correct / total) * 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
color: '#1f2937',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
Quiz Results
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
background: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(45deg, #3b82f6, #2563eb)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
<span>{percentage}%</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: '500', color: '#6b7280' }}>Correct:</span>
|
||||
<span style={{ fontWeight: 'bold' }}>{correct}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: '500', color: '#6b7280' }}>Total:</span>
|
||||
<span style={{ fontWeight: 'bold' }}>{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results card grid - reuse CardGrid but with all cards revealed and status indicators */}
|
||||
<div style={{ marginTop: '12px', flex: 1, overflow: 'auto' }}>
|
||||
<ResultsCardGrid state={state} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '16px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
onClick={() => {
|
||||
resetGame?.()
|
||||
const quizCards = generateQuizCards(
|
||||
state.selectedCount,
|
||||
state.selectedDifficulty,
|
||||
appConfig
|
||||
)
|
||||
startQuiz?.(quizCards)
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
onClick={() => resetGame?.()}
|
||||
>
|
||||
Back to Cards
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
262
apps/web/src/app/arcade/memory-quiz/components/SetupPhase.tsx
Normal file
262
apps/web/src/app/arcade/memory-quiz/components/SetupPhase.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
|
||||
|
||||
// Generate quiz cards with difficulty-based number ranges
|
||||
const generateQuizCards = (
|
||||
count: number,
|
||||
difficulty: DifficultyLevel,
|
||||
appConfig: any
|
||||
): QuizCard[] => {
|
||||
const { min, max } = DIFFICULTY_LEVELS[difficulty].range
|
||||
|
||||
// Generate unique numbers - no duplicates allowed
|
||||
const numbers: number[] = []
|
||||
const maxAttempts = (max - min + 1) * 10 // Prevent infinite loops
|
||||
let attempts = 0
|
||||
|
||||
while (numbers.length < count && attempts < maxAttempts) {
|
||||
const newNumber = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
if (!numbers.includes(newNumber)) {
|
||||
numbers.push(newNumber)
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
|
||||
// If we couldn't generate enough unique numbers, fill with sequential numbers
|
||||
if (numbers.length < count) {
|
||||
for (let i = min; i <= max && numbers.length < count; i++) {
|
||||
if (!numbers.includes(i)) {
|
||||
numbers.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return numbers.map((number) => ({
|
||||
number,
|
||||
svgComponent: (
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns="auto"
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={1.0}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
soundEnabled={appConfig.soundEnabled}
|
||||
soundVolume={appConfig.soundVolume}
|
||||
/>
|
||||
),
|
||||
element: null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, setConfig, startQuiz } = useMemoryQuiz()
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
const handleCountSelect = (count: number) => {
|
||||
setConfig?.('selectedCount', count)
|
||||
}
|
||||
|
||||
const handleTimeChange = (time: number) => {
|
||||
setConfig?.('displayTime', time)
|
||||
}
|
||||
|
||||
const handleDifficultySelect = (difficulty: DifficultyLevel) => {
|
||||
setConfig?.('selectedDifficulty', difficulty)
|
||||
}
|
||||
|
||||
const handleStartQuiz = () => {
|
||||
const quizCards = generateQuizCards(
|
||||
state.selectedCount ?? 5,
|
||||
state.selectedDifficulty ?? 'easy',
|
||||
appConfig
|
||||
)
|
||||
startQuiz?.(quizCards)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Difficulty Level:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{Object.entries(DIFFICULTY_LEVELS).map(([key, level]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
style={{
|
||||
background: state.selectedDifficulty === key ? '#3b82f6' : 'white',
|
||||
color: state.selectedDifficulty === key ? 'white' : '#1f2937',
|
||||
border: '2px solid',
|
||||
borderColor: state.selectedDifficulty === key ? '#3b82f6' : '#d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
onClick={() => handleDifficultySelect(key as DifficultyLevel)}
|
||||
title={level.description}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>{level.name}</div>
|
||||
<div style={{ fontSize: '10px', opacity: 0.8 }}>{level.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Cards to Quiz:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{[2, 5, 8, 12, 15].map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
style={{
|
||||
background: state.selectedCount === count ? '#3b82f6' : 'white',
|
||||
color: state.selectedCount === count ? 'white' : '#1f2937',
|
||||
border: '2px solid',
|
||||
borderColor: state.selectedCount === count ? '#3b82f6' : '#d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
minWidth: '50px',
|
||||
}}
|
||||
onClick={() => handleCountSelect(count)}
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Display Time per Card:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={state.displayTime ?? 2.0}
|
||||
onChange={(e) => handleTimeChange(parseFloat(e.target.value))}
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#3b82f6',
|
||||
minWidth: '40px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{(state.displayTime ?? 2.0).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 24px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
marginTop: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
onClick={handleStartQuiz}
|
||||
>
|
||||
Start Quiz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useReducer } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { initialState, quizReducer } from '../reducer'
|
||||
import type { QuizCard } from '../types'
|
||||
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
|
||||
|
||||
interface LocalMemoryQuizProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalMemoryQuizProvider - Provides context for single-player local mode
|
||||
*
|
||||
* This provider wraps the memory quiz reducer and provides action creators
|
||||
* to child components. It's used for standalone local play (non-room mode).
|
||||
*
|
||||
* Action creators wrap dispatch calls to maintain same interface as RoomProvider.
|
||||
*/
|
||||
export function LocalMemoryQuizProvider({ children }: LocalMemoryQuizProviderProps) {
|
||||
const router = useRouter()
|
||||
const [state, dispatch] = useReducer(quizReducer, initialState)
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (state.prefixAcceptanceTimeout) {
|
||||
clearTimeout(state.prefixAcceptanceTimeout)
|
||||
}
|
||||
}
|
||||
}, [state.prefixAcceptanceTimeout])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
|
||||
|
||||
// Action creators - wrap dispatch calls to match RoomProvider interface
|
||||
const startQuiz = useCallback((quizCards: QuizCard[]) => {
|
||||
dispatch({ type: 'START_QUIZ', quizCards })
|
||||
}, [])
|
||||
|
||||
const nextCard = useCallback(() => {
|
||||
dispatch({ type: 'NEXT_CARD' })
|
||||
}, [])
|
||||
|
||||
const showInputPhase = useCallback(() => {
|
||||
dispatch({ type: 'SHOW_INPUT_PHASE' })
|
||||
}, [])
|
||||
|
||||
const acceptNumber = useCallback((number: number) => {
|
||||
dispatch({ type: 'ACCEPT_NUMBER', number })
|
||||
}, [])
|
||||
|
||||
const rejectNumber = useCallback(() => {
|
||||
dispatch({ type: 'REJECT_NUMBER' })
|
||||
}, [])
|
||||
|
||||
const setInput = useCallback((input: string) => {
|
||||
dispatch({ type: 'SET_INPUT', input })
|
||||
}, [])
|
||||
|
||||
const showResults = useCallback(() => {
|
||||
dispatch({ type: 'SHOW_RESULTS' })
|
||||
}, [])
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
dispatch({ type: 'RESET_QUIZ' })
|
||||
}, [])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
|
||||
switch (field) {
|
||||
case 'selectedCount':
|
||||
dispatch({ type: 'SET_SELECTED_COUNT', count: value })
|
||||
break
|
||||
case 'displayTime':
|
||||
dispatch({ type: 'SET_DISPLAY_TIME', time: value })
|
||||
break
|
||||
case 'selectedDifficulty':
|
||||
dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
|
||||
break
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const exitSession = useCallback(() => {
|
||||
router.push('/games')
|
||||
}, [router])
|
||||
|
||||
const contextValue: MemoryQuizContextValue = {
|
||||
state,
|
||||
dispatch: () => {
|
||||
// No-op - local provider uses action creators instead
|
||||
console.warn('dispatch() is not available in local mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
resetGame,
|
||||
exitSession,
|
||||
// Expose action creators for components to use
|
||||
startQuiz,
|
||||
nextCard,
|
||||
showInputPhase,
|
||||
acceptNumber,
|
||||
rejectNumber,
|
||||
setInput,
|
||||
showResults,
|
||||
setConfig,
|
||||
}
|
||||
|
||||
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { QuizAction, QuizCard, SorobanQuizState } from '../types'
|
||||
|
||||
// Context value interface
|
||||
export interface MemoryQuizContextValue {
|
||||
state: SorobanQuizState
|
||||
dispatch: React.Dispatch<QuizAction>
|
||||
|
||||
// Computed values
|
||||
isGameActive: boolean
|
||||
|
||||
// Action creators (to be implemented by providers)
|
||||
// Local mode uses dispatch, room mode uses these action creators
|
||||
startGame?: () => void
|
||||
resetGame?: () => void
|
||||
exitSession?: () => void
|
||||
|
||||
// Room mode action creators (optional for local mode)
|
||||
startQuiz?: (quizCards: QuizCard[]) => void
|
||||
nextCard?: () => void
|
||||
showInputPhase?: () => void
|
||||
acceptNumber?: (number: number) => void
|
||||
rejectNumber?: () => void
|
||||
setInput?: (input: string) => void
|
||||
showResults?: () => void
|
||||
setConfig?: (field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => void
|
||||
}
|
||||
|
||||
// Create context
|
||||
export const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
|
||||
|
||||
// Hook to use the context
|
||||
export function useMemoryQuiz(): MemoryQuizContextValue {
|
||||
const context = useContext(MemoryQuizContext)
|
||||
if (!context) {
|
||||
throw new Error('useMemoryQuiz must be used within a MemoryQuizProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { initialState } from '../reducer'
|
||||
import type { QuizCard, SorobanQuizState } from '../types'
|
||||
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* The server will validate and send back the authoritative state
|
||||
*/
|
||||
function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): SorobanQuizState {
|
||||
switch (move.type) {
|
||||
case 'START_QUIZ': {
|
||||
// Handle both client-generated moves (with quizCards) and server-generated moves (with numbers only)
|
||||
// Server can't serialize React components, so it only sends numbers
|
||||
const clientQuizCards = move.data.quizCards
|
||||
const serverNumbers = move.data.numbers
|
||||
|
||||
let quizCards: QuizCard[]
|
||||
let correctAnswers: number[]
|
||||
|
||||
if (clientQuizCards) {
|
||||
// Client-side optimistic update: use the full quizCards with React components
|
||||
quizCards = clientQuizCards
|
||||
correctAnswers = clientQuizCards.map((card: QuizCard) => card.number)
|
||||
} else if (serverNumbers) {
|
||||
// Server update: create minimal quizCards from numbers (no React components needed for validation)
|
||||
quizCards = serverNumbers.map((number: number) => ({
|
||||
number,
|
||||
svgComponent: null,
|
||||
element: null,
|
||||
}))
|
||||
correctAnswers = serverNumbers
|
||||
} else {
|
||||
// Fallback: preserve existing state
|
||||
quizCards = state.quizCards
|
||||
correctAnswers = state.correctAnswers
|
||||
}
|
||||
|
||||
const cardCount = quizCards.length
|
||||
|
||||
return {
|
||||
...state,
|
||||
quizCards,
|
||||
correctAnswers,
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: cardCount + Math.floor(cardCount / 2),
|
||||
gamePhase: 'display',
|
||||
incorrectGuesses: 0,
|
||||
currentInput: '',
|
||||
wrongGuessAnimations: [],
|
||||
prefixAcceptanceTimeout: null,
|
||||
}
|
||||
}
|
||||
|
||||
case 'NEXT_CARD':
|
||||
return {
|
||||
...state,
|
||||
currentCardIndex: state.currentCardIndex + 1,
|
||||
}
|
||||
|
||||
case 'SHOW_INPUT_PHASE':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'input',
|
||||
}
|
||||
|
||||
case 'ACCEPT_NUMBER':
|
||||
return {
|
||||
...state,
|
||||
foundNumbers: [...state.foundNumbers, move.data.number],
|
||||
currentInput: '',
|
||||
}
|
||||
|
||||
case 'REJECT_NUMBER':
|
||||
return {
|
||||
...state,
|
||||
guessesRemaining: state.guessesRemaining - 1,
|
||||
incorrectGuesses: state.incorrectGuesses + 1,
|
||||
currentInput: '',
|
||||
}
|
||||
|
||||
case 'SET_INPUT':
|
||||
return {
|
||||
...state,
|
||||
currentInput: move.data.input,
|
||||
}
|
||||
|
||||
case 'SHOW_RESULTS':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
}
|
||||
|
||||
case 'RESET_QUIZ':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
quizCards: [],
|
||||
correctAnswers: [],
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: 0,
|
||||
currentInput: '',
|
||||
incorrectGuesses: 0,
|
||||
wrongGuessAnimations: [],
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
const { field, value } = move.data as {
|
||||
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
|
||||
value: any
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RoomMemoryQuizProvider - Provides context for room-based multiplayer mode
|
||||
*
|
||||
* This provider uses useArcadeSession for network-synchronized gameplay.
|
||||
* All state changes are sent as moves and validated on the server.
|
||||
*/
|
||||
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
|
||||
// Arcade session integration WITH room sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
connected: _connected,
|
||||
exitSession,
|
||||
} = useArcadeSession<SorobanQuizState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (state.prefixAcceptanceTimeout) {
|
||||
clearTimeout(state.prefixAcceptanceTimeout)
|
||||
}
|
||||
}
|
||||
}, [state.prefixAcceptanceTimeout])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
|
||||
|
||||
// Action creators - send moves to arcade session
|
||||
// For single-player quiz, we use viewerId as playerId
|
||||
const startQuiz = useCallback(
|
||||
(quizCards: QuizCard[]) => {
|
||||
// Extract only serializable data (numbers) for server
|
||||
// React components can't be sent over Socket.IO
|
||||
const numbers = quizCards.map((card) => card.number)
|
||||
|
||||
sendMove({
|
||||
type: 'START_QUIZ',
|
||||
playerId: viewerId || '',
|
||||
data: {
|
||||
numbers, // Send to server
|
||||
quizCards, // Keep for optimistic local update
|
||||
},
|
||||
})
|
||||
},
|
||||
[viewerId, sendMove]
|
||||
)
|
||||
|
||||
const nextCard = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_CARD',
|
||||
playerId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const showInputPhase = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'SHOW_INPUT_PHASE',
|
||||
playerId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const acceptNumber = useCallback(
|
||||
(number: number) => {
|
||||
sendMove({
|
||||
type: 'ACCEPT_NUMBER',
|
||||
playerId: viewerId || '',
|
||||
data: { number },
|
||||
})
|
||||
},
|
||||
[viewerId, sendMove]
|
||||
)
|
||||
|
||||
const rejectNumber = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'REJECT_NUMBER',
|
||||
playerId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const setInput = useCallback(
|
||||
(input: string) => {
|
||||
sendMove({
|
||||
type: 'SET_INPUT',
|
||||
playerId: viewerId || '',
|
||||
data: { input },
|
||||
})
|
||||
},
|
||||
[viewerId, sendMove]
|
||||
)
|
||||
|
||||
const showResults = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'SHOW_RESULTS',
|
||||
playerId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'RESET_QUIZ',
|
||||
playerId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
},
|
||||
[viewerId, sendMove]
|
||||
)
|
||||
|
||||
const contextValue: MemoryQuizContextValue = {
|
||||
state,
|
||||
dispatch: () => {
|
||||
// No-op - replaced with action creators
|
||||
console.warn('dispatch() is deprecated in room mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
resetGame,
|
||||
exitSession,
|
||||
// Expose action creators for components to use
|
||||
startQuiz,
|
||||
nextCard,
|
||||
showInputPhase,
|
||||
acceptNumber,
|
||||
rejectNumber,
|
||||
setInput,
|
||||
showResults,
|
||||
setConfig,
|
||||
}
|
||||
|
||||
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
|
||||
}
|
||||
|
||||
// Export the hook for this provider
|
||||
export { useMemoryQuiz } from './MemoryQuizContext'
|
||||
File diff suppressed because it is too large
Load Diff
106
apps/web/src/app/arcade/memory-quiz/reducer.ts
Normal file
106
apps/web/src/app/arcade/memory-quiz/reducer.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { QuizAction, SorobanQuizState } from './types'
|
||||
|
||||
export const initialState: SorobanQuizState = {
|
||||
cards: [],
|
||||
quizCards: [],
|
||||
correctAnswers: [],
|
||||
currentCardIndex: 0,
|
||||
displayTime: 2.0,
|
||||
selectedCount: 5,
|
||||
selectedDifficulty: 'easy', // Default to easy level
|
||||
foundNumbers: [],
|
||||
guessesRemaining: 0,
|
||||
currentInput: '',
|
||||
incorrectGuesses: 0,
|
||||
gamePhase: 'setup',
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
wrongGuessAnimations: [],
|
||||
// Keyboard state (persistent across re-renders)
|
||||
hasPhysicalKeyboard: null,
|
||||
testingMode: false,
|
||||
showOnScreenKeyboard: false,
|
||||
}
|
||||
|
||||
export function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizState {
|
||||
switch (action.type) {
|
||||
case 'SET_CARDS':
|
||||
return { ...state, cards: action.cards }
|
||||
case 'SET_DISPLAY_TIME':
|
||||
return { ...state, displayTime: action.time }
|
||||
case 'SET_SELECTED_COUNT':
|
||||
return { ...state, selectedCount: action.count }
|
||||
case 'SET_DIFFICULTY':
|
||||
return { ...state, selectedDifficulty: action.difficulty }
|
||||
case 'START_QUIZ':
|
||||
return {
|
||||
...state,
|
||||
quizCards: action.quizCards,
|
||||
correctAnswers: action.quizCards.map((card) => card.number),
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: action.quizCards.length + Math.floor(action.quizCards.length / 2),
|
||||
gamePhase: 'display',
|
||||
}
|
||||
case 'NEXT_CARD':
|
||||
return { ...state, currentCardIndex: state.currentCardIndex + 1 }
|
||||
case 'SHOW_INPUT_PHASE':
|
||||
return { ...state, gamePhase: 'input' }
|
||||
case 'ACCEPT_NUMBER':
|
||||
return {
|
||||
...state,
|
||||
foundNumbers: [...state.foundNumbers, action.number],
|
||||
currentInput: '',
|
||||
}
|
||||
case 'REJECT_NUMBER':
|
||||
return {
|
||||
...state,
|
||||
guessesRemaining: state.guessesRemaining - 1,
|
||||
incorrectGuesses: state.incorrectGuesses + 1,
|
||||
currentInput: '',
|
||||
}
|
||||
case 'SET_INPUT':
|
||||
return { ...state, currentInput: action.input }
|
||||
case 'SET_PREFIX_TIMEOUT':
|
||||
return { ...state, prefixAcceptanceTimeout: action.timeout }
|
||||
case 'ADD_WRONG_GUESS_ANIMATION':
|
||||
return {
|
||||
...state,
|
||||
wrongGuessAnimations: [
|
||||
...state.wrongGuessAnimations,
|
||||
{
|
||||
number: action.number,
|
||||
id: `wrong-${action.number}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}
|
||||
case 'CLEAR_WRONG_GUESS_ANIMATIONS':
|
||||
return {
|
||||
...state,
|
||||
wrongGuessAnimations: [],
|
||||
}
|
||||
case 'SHOW_RESULTS':
|
||||
return { ...state, gamePhase: 'results' }
|
||||
case 'RESET_QUIZ':
|
||||
return {
|
||||
...initialState,
|
||||
cards: state.cards, // Preserve generated cards
|
||||
displayTime: state.displayTime,
|
||||
selectedCount: state.selectedCount,
|
||||
selectedDifficulty: state.selectedDifficulty,
|
||||
// Preserve keyboard state across resets
|
||||
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
|
||||
testingMode: state.testingMode,
|
||||
showOnScreenKeyboard: state.showOnScreenKeyboard,
|
||||
}
|
||||
case 'SET_PHYSICAL_KEYBOARD':
|
||||
return { ...state, hasPhysicalKeyboard: action.hasKeyboard }
|
||||
case 'SET_TESTING_MODE':
|
||||
return { ...state, testingMode: action.enabled }
|
||||
case 'TOGGLE_ONSCREEN_KEYBOARD':
|
||||
return { ...state, showOnScreenKeyboard: !state.showOnScreenKeyboard }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
90
apps/web/src/app/arcade/memory-quiz/types.ts
Normal file
90
apps/web/src/app/arcade/memory-quiz/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export interface QuizCard {
|
||||
number: number
|
||||
svgComponent: JSX.Element
|
||||
element: HTMLElement | null
|
||||
}
|
||||
|
||||
export interface SorobanQuizState {
|
||||
// Core game data
|
||||
cards: QuizCard[]
|
||||
quizCards: QuizCard[]
|
||||
correctAnswers: number[]
|
||||
|
||||
// Game progression
|
||||
currentCardIndex: number
|
||||
displayTime: number
|
||||
selectedCount: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
|
||||
// Input system state
|
||||
foundNumbers: number[]
|
||||
guessesRemaining: number
|
||||
currentInput: string
|
||||
incorrectGuesses: number
|
||||
|
||||
// UI state
|
||||
gamePhase: 'setup' | 'display' | 'input' | 'results'
|
||||
prefixAcceptanceTimeout: NodeJS.Timeout | null
|
||||
finishButtonsBound: boolean
|
||||
wrongGuessAnimations: Array<{
|
||||
number: number
|
||||
id: string
|
||||
timestamp: number
|
||||
}>
|
||||
|
||||
// Keyboard state (moved from InputPhase to persist across re-renders)
|
||||
hasPhysicalKeyboard: boolean | null
|
||||
testingMode: boolean
|
||||
showOnScreenKeyboard: boolean
|
||||
}
|
||||
|
||||
export type QuizAction =
|
||||
| { type: 'SET_CARDS'; cards: QuizCard[] }
|
||||
| { type: 'SET_DISPLAY_TIME'; time: number }
|
||||
| { type: 'SET_SELECTED_COUNT'; count: number }
|
||||
| { type: 'SET_DIFFICULTY'; difficulty: DifficultyLevel }
|
||||
| { type: 'START_QUIZ'; quizCards: QuizCard[] }
|
||||
| { type: 'NEXT_CARD' }
|
||||
| { type: 'SHOW_INPUT_PHASE' }
|
||||
| { type: 'ACCEPT_NUMBER'; number: number }
|
||||
| { type: 'REJECT_NUMBER' }
|
||||
| { type: 'ADD_WRONG_GUESS_ANIMATION'; number: number }
|
||||
| { type: 'CLEAR_WRONG_GUESS_ANIMATIONS' }
|
||||
| { type: 'SET_INPUT'; input: string }
|
||||
| { type: 'SET_PREFIX_TIMEOUT'; timeout: NodeJS.Timeout | null }
|
||||
| { type: 'SHOW_RESULTS' }
|
||||
| { type: 'RESET_QUIZ' }
|
||||
| { type: 'SET_PHYSICAL_KEYBOARD'; hasKeyboard: boolean | null }
|
||||
| { type: 'SET_TESTING_MODE'; enabled: boolean }
|
||||
| { type: 'TOGGLE_ONSCREEN_KEYBOARD' }
|
||||
|
||||
// Difficulty levels with progressive number ranges
|
||||
export const DIFFICULTY_LEVELS = {
|
||||
beginner: {
|
||||
name: 'Beginner',
|
||||
range: { min: 1, max: 9 },
|
||||
description: 'Single digits (1-9)',
|
||||
},
|
||||
easy: {
|
||||
name: 'Easy',
|
||||
range: { min: 10, max: 99 },
|
||||
description: 'Two digits (10-99)',
|
||||
},
|
||||
medium: {
|
||||
name: 'Medium',
|
||||
range: { min: 100, max: 499 },
|
||||
description: 'Three digits (100-499)',
|
||||
},
|
||||
hard: {
|
||||
name: 'Hard',
|
||||
range: { min: 500, max: 999 },
|
||||
description: 'Large numbers (500-999)',
|
||||
},
|
||||
expert: {
|
||||
name: 'Expert',
|
||||
range: { min: 1, max: 999 },
|
||||
description: 'Mixed range (1-999)',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type DifficultyLevel = keyof typeof DIFFICULTY_LEVELS
|
||||
@@ -78,7 +78,7 @@ function ArcadeContent() {
|
||||
|
||||
function ArcadePageWithRedirect() {
|
||||
return (
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizeGameContext={true}>
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
|
||||
<ArcadeContent />
|
||||
</PageWithNav>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
|
||||
import { MemoryQuizGame } from '../memory-quiz/components/MemoryQuizGame'
|
||||
import { RoomMemoryQuizProvider } from '../memory-quiz/context/RoomMemoryQuizProvider'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
// Map GameType keys to internal game names
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
'battle-arena': 'matching',
|
||||
'memory-quiz': 'memory-quiz',
|
||||
'complement-race': 'complement-race',
|
||||
'master-organizer': 'master-organizer',
|
||||
}
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
|
||||
* Instead, we show a friendly message with a link back to the Champion Arena.
|
||||
*
|
||||
@@ -15,7 +33,9 @@ import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProv
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
@@ -64,6 +84,139 @@ export default function RoomPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
const gameConfig = GAMES_CONFIG[gameType]
|
||||
console.log('[RoomPage] Game config:', {
|
||||
name: gameConfig.name,
|
||||
available: gameConfig.available,
|
||||
})
|
||||
|
||||
if (gameConfig.available === false) {
|
||||
console.log('[RoomPage] Game not available, blocking selection')
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
|
||||
// Map GameType to internal game name
|
||||
const internalGameName = GAME_TYPE_TO_NAME[gameType]
|
||||
console.log('[RoomPage] Mapping:', {
|
||||
gameType,
|
||||
internalGameName,
|
||||
mappingExists: !!internalGameName,
|
||||
})
|
||||
|
||||
console.log('[RoomPage] Calling setRoomGame with:', {
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
gameConfig: {},
|
||||
})
|
||||
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
gameConfig: {},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={config.available === false}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: config.available === false ? 'not-allowed' : 'pointer',
|
||||
opacity: config.available === false ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover:
|
||||
config.available === false
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the appropriate game based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
@@ -73,21 +226,35 @@ export default function RoomPage() {
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
case 'memory-quiz':
|
||||
return (
|
||||
<RoomMemoryQuizProvider>
|
||||
<MemoryQuizGame />
|
||||
</RoomMemoryQuizProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, etc.)
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MemoryPairsGame() {
|
||||
navTitle={navTitle}
|
||||
navEmoji={navEmoji}
|
||||
gameName="matching"
|
||||
emphasizeGameContext={state.gamePhase === 'setup'}
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={state.currentPlayer}
|
||||
playerScores={state.scores}
|
||||
playerStreaks={state.consecutiveMatches}
|
||||
|
||||
@@ -21,7 +21,7 @@ function GamesPageContent() {
|
||||
const _handleGameClick = (gameType: string) => {
|
||||
// Navigate directly to games using the centralized game mode with Next.js router
|
||||
console.log('🔄 GamesPage: Navigating with Next.js router (no page reload)')
|
||||
if (gameType === 'memory-lightning') {
|
||||
if (gameType === 'memory-quiz') {
|
||||
router.push('/games/memory-quiz')
|
||||
} else if (gameType === 'battle-arena') {
|
||||
router.push('/games/matching')
|
||||
|
||||
@@ -23,10 +23,23 @@ export function GameCard({ gameType, config, variant = 'detailed', className }:
|
||||
}
|
||||
|
||||
const handleGameClick = () => {
|
||||
console.log(`[GameCard] Clicked on ${config.name}:`, {
|
||||
activePlayerCount,
|
||||
maxPlayers: config.maxPlayers,
|
||||
isGameAvailable: isGameAvailable(),
|
||||
configAvailable: config.available,
|
||||
willNavigate: isGameAvailable() && config.available !== false,
|
||||
url: config.url,
|
||||
})
|
||||
|
||||
if (isGameAvailable() && config.available !== false) {
|
||||
console.log('🔄 GameCard: Navigating with Next.js router (no page reload)')
|
||||
// Use Next.js router for client-side navigation - this preserves fullscreen!
|
||||
router.push(config.url)
|
||||
} else {
|
||||
console.warn('❌ GameCard: Navigation blocked', {
|
||||
reason: !isGameAvailable() ? 'Player count mismatch' : 'Game not available',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GameCard } from './GameCard'
|
||||
|
||||
// Game configuration defining player limits
|
||||
export const GAMES_CONFIG = {
|
||||
'memory-lightning': {
|
||||
'memory-quiz': {
|
||||
name: 'Memory Lightning',
|
||||
fullName: 'Memory Lightning ⚡',
|
||||
maxPlayers: 1,
|
||||
|
||||
@@ -13,7 +13,7 @@ interface PageWithNavProps {
|
||||
navTitle?: string
|
||||
navEmoji?: string
|
||||
gameName?: 'matching' | 'memory-quiz' | 'complement-race' // Internal game name for API
|
||||
emphasizeGameContext?: boolean
|
||||
emphasizePlayerSelection?: boolean
|
||||
onExitSession?: () => void
|
||||
onSetup?: () => void
|
||||
onNewGame?: () => void
|
||||
@@ -28,7 +28,7 @@ export function PageWithNav({
|
||||
navTitle,
|
||||
navEmoji,
|
||||
gameName,
|
||||
emphasizeGameContext = false,
|
||||
emphasizePlayerSelection = false,
|
||||
onExitSession,
|
||||
onSetup,
|
||||
onNewGame,
|
||||
@@ -103,7 +103,7 @@ export function PageWithNav({
|
||||
? 'tournament'
|
||||
: 'none'
|
||||
|
||||
const shouldEmphasize = emphasizeGameContext && mounted
|
||||
const shouldEmphasize = emphasizePlayerSelection && mounted
|
||||
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
|
||||
|
||||
// Compute arcade session info for display
|
||||
|
||||
@@ -179,7 +179,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes slideIn {
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + 25px));
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
@keyframes toastSlideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
@@ -197,7 +197,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hide {
|
||||
@keyframes toastHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -206,25 +206,25 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
[data-state='open'] {
|
||||
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
[data-radix-toast-viewport] [data-state='open'] {
|
||||
animation: toastSlideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
[data-state='closed'] {
|
||||
animation: hide 100ms ease-in, slideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
|
||||
[data-radix-toast-viewport] [data-state='closed'] {
|
||||
animation: toastHide 100ms ease-in, toastSlideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
|
||||
}
|
||||
|
||||
[data-swipe='move'] {
|
||||
[data-radix-toast-viewport] [data-swipe='move'] {
|
||||
transform: translateX(var(--radix-toast-swipe-move-x));
|
||||
}
|
||||
|
||||
[data-swipe='cancel'] {
|
||||
[data-radix-toast-viewport] [data-swipe='cancel'] {
|
||||
transform: translateX(0);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
|
||||
[data-swipe='end'] {
|
||||
animation: slideOut 100ms ease-out;
|
||||
[data-radix-toast-viewport] [data-swipe='end'] {
|
||||
animation: toastSlideOut 100ms ease-out;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
|
||||
@@ -62,12 +62,12 @@ export function AddPlayerButton({
|
||||
const { mutate: joinRoom } = useJoinRoom()
|
||||
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
|
||||
|
||||
// Handler for creating a new room
|
||||
// Handler for creating a new room (without a game - game will be selected in room)
|
||||
const handleCreateRoom = () => {
|
||||
createRoom(
|
||||
{
|
||||
name: `${gameName} Room`,
|
||||
gameName: gameName,
|
||||
name: null, // Auto-generated from code
|
||||
gameName: null, // No game selected yet - will be chosen in room
|
||||
creatorName: 'Player',
|
||||
},
|
||||
{
|
||||
@@ -78,10 +78,9 @@ export function AddPlayerButton({
|
||||
name: data.name,
|
||||
gameName: data.gameName,
|
||||
})
|
||||
// Close popover
|
||||
// Close popover and navigate to room to choose game
|
||||
setShowPopover(false)
|
||||
// Navigate to the room page
|
||||
router.push(`/arcade/rooms/${data.id}`)
|
||||
router.push('/arcade/room')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create room:', error)
|
||||
@@ -110,8 +109,9 @@ export function AddPlayerButton({
|
||||
gameName: data.room.gameName,
|
||||
})
|
||||
}
|
||||
// Close popover
|
||||
// Close popover and navigate to room
|
||||
setShowPopover(false)
|
||||
router.push('/arcade/room')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -836,7 +836,10 @@ export function ModerationNotifications({
|
||||
router.push('/arcade/room')
|
||||
} catch (error) {
|
||||
console.error('Failed to join room:', error)
|
||||
showError('Failed to join room', error instanceof Error ? error.message : undefined)
|
||||
showError(
|
||||
'Failed to join room',
|
||||
error instanceof Error ? error.message : undefined
|
||||
)
|
||||
setIsAcceptingInvitation(false)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
import { ReportPlayerModal } from './ReportPlayerModal'
|
||||
|
||||
|
||||
@@ -270,75 +270,85 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: '16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = gradientColor
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateNewName}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)'
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px ${gradientColor}40`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
title="Generate random name"
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span>Click dice to generate a random name</span>
|
||||
<span>{localName.length}/20 characters</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
fontSize: '16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = gradientColor
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '6px',
|
||||
}}
|
||||
>
|
||||
{localName.length}/20 characters
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateNewName}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)'
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px ${gradientColor}40`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
title="Generate random name"
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '6px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Random name
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { useClearRoomGame, useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
import { CreateRoomModal } from './CreateRoomModal'
|
||||
@@ -62,6 +62,7 @@ export function RoomInfo({
|
||||
const { getRoomShareUrl, roomData } = useRoomData()
|
||||
const { data: currentUserId } = useViewerId()
|
||||
const { mutateAsync: leaveRoom } = useLeaveRoom()
|
||||
const { mutate: clearRoomGame } = useClearRoomGame()
|
||||
|
||||
// Use room display utility for consistent naming
|
||||
const displayName = joinCode
|
||||
@@ -403,6 +404,43 @@ export function RoomInfo({
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{/* Change Game - only show for host and only when a game is selected */}
|
||||
{isCurrentUserCreator && roomId && roomData?.gameName && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (roomId) {
|
||||
clearRoomGame(roomId)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(236, 72, 153, 0.2)'
|
||||
e.currentTarget.style.color = 'rgba(249, 168, 212, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>🔄</span>
|
||||
<span>Change Game</span>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{/* Moderation - only show for host */}
|
||||
{isCurrentUserCreator && roomId && (
|
||||
<DropdownMenu.Item
|
||||
|
||||
@@ -32,11 +32,11 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
|
||||
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
|
||||
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
|
||||
|
||||
// Game configuration
|
||||
// Game configuration (nullable to support game selection in room)
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
gameConfig: text('game_config', { mode: 'json' }).notNull(), // Game-specific settings
|
||||
}),
|
||||
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)
|
||||
|
||||
// Current state
|
||||
status: text('status', {
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface RoomData {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
gameName: string
|
||||
gameName: string | null // Nullable to support game selection in room
|
||||
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
|
||||
@@ -30,7 +30,7 @@ export interface RoomData {
|
||||
|
||||
export interface CreateRoomParams {
|
||||
name: string | null
|
||||
gameName: string
|
||||
gameName?: string | null // Optional - rooms can be created without a game
|
||||
creatorName?: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
@@ -86,9 +86,9 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: params.name,
|
||||
gameName: params.gameName,
|
||||
gameName: params.gameName || null,
|
||||
creatorName: params.creatorName || 'Player',
|
||||
gameConfig: params.gameConfig || { difficulty: 6 },
|
||||
gameConfig: params.gameConfig || null,
|
||||
accessMode: params.accessMode,
|
||||
password: params.password,
|
||||
}),
|
||||
@@ -558,3 +558,91 @@ export function useGetRoomByCode() {
|
||||
mutationFn: getRoomByCodeApi,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set game for a room
|
||||
*/
|
||||
async function setRoomGameApi(params: {
|
||||
roomId: string
|
||||
gameName: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameName: params.gameName,
|
||||
gameConfig: params.gameConfig || {},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to set room game')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Set game for a room
|
||||
*/
|
||||
export function useSetRoomGame() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: setRoomGameApi,
|
||||
onSuccess: (_, variables) => {
|
||||
// Update the cache with the new game
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameName: variables.gameName,
|
||||
}
|
||||
})
|
||||
// Refetch to get the full updated room data
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear/reset game for a room (host only)
|
||||
*/
|
||||
async function clearRoomGameApi(roomId: string): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameName: null,
|
||||
gameConfig: null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to clear room game')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Clear/reset game for a room (returns to game selection screen)
|
||||
*/
|
||||
export function useClearRoomGame() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: clearRoomGameApi,
|
||||
onSuccess: () => {
|
||||
// Update the cache to clear the game
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameName: null,
|
||||
}
|
||||
})
|
||||
// Refetch to get the full updated room data
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('Arcade Session Integration', () => {
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: ['1'],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
@@ -76,6 +77,7 @@ describe('Arcade Session Integration', () => {
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
const session = await createArcadeSession({
|
||||
@@ -170,6 +172,7 @@ describe('Arcade Session Integration', () => {
|
||||
moves: 0,
|
||||
scores: { 1: 0 },
|
||||
activePlayers: ['1'],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: { 1: 0 },
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
@@ -179,6 +182,7 @@ describe('Arcade Session Integration', () => {
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
await createArcadeSession({
|
||||
|
||||
@@ -52,38 +52,12 @@ describe('Orphaned Session Cleanup', () => {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
it('should return undefined when session has no roomId', async () => {
|
||||
// Create a session with a valid room
|
||||
const session = await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: testRoomId,
|
||||
})
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.roomId).toBe(testRoomId)
|
||||
|
||||
// Manually set roomId to null to simulate orphaned session
|
||||
await db
|
||||
.update(schema.arcadeSessions)
|
||||
.set({ roomId: null })
|
||||
.where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
|
||||
// Getting the session should auto-delete it and return undefined
|
||||
const result = await getArcadeSession(testGuestId)
|
||||
expect(result).toBeUndefined()
|
||||
|
||||
// Verify session was actually deleted
|
||||
const [directCheck] = await db
|
||||
.select()
|
||||
.from(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
.limit(1)
|
||||
|
||||
expect(directCheck).toBeUndefined()
|
||||
// NOTE: This test is no longer valid with roomId as primary key
|
||||
// roomId cannot be null since it's the primary key with a foreign key constraint
|
||||
// Orphaned sessions are now automatically cleaned up via CASCADE delete when room is deleted
|
||||
it.skip('should return undefined when session has no roomId', async () => {
|
||||
// This test scenario is impossible with the new schema where roomId is the primary key
|
||||
// and has a foreign key constraint with CASCADE delete
|
||||
})
|
||||
|
||||
it('should return undefined when session room has been deleted', async () => {
|
||||
|
||||
@@ -5,13 +5,7 @@
|
||||
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
roomBans,
|
||||
roomMembers,
|
||||
roomReports,
|
||||
type NewRoomBan,
|
||||
type NewRoomReport,
|
||||
} from '@/db/schema'
|
||||
import { roomBans, roomMembers, roomReports } from '@/db/schema'
|
||||
import { recordRoomMemberHistory } from './room-member-history'
|
||||
|
||||
/**
|
||||
|
||||
@@ -220,8 +220,10 @@ export async function applyGameMove(
|
||||
const validator = getValidator(session.currentGame as GameName)
|
||||
|
||||
console.log('[SessionManager] About to validate move:', {
|
||||
gameName: session.currentGame,
|
||||
moveType: move.type,
|
||||
playerId: move.playerId,
|
||||
moveData: move.type === 'SET_CONFIG' ? (move as any).data : undefined,
|
||||
gameStateCurrentPlayer: (session.gameState as any)?.currentPlayer,
|
||||
gameStateActivePlayers: (session.gameState as any)?.activePlayers,
|
||||
gameStatePhase: (session.gameState as any)?.gamePhase,
|
||||
|
||||
370
apps/web/src/lib/arcade/validation/MemoryQuizGameValidator.ts
Normal file
370
apps/web/src/lib/arcade/validation/MemoryQuizGameValidator.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Server-side validator for memory-quiz game
|
||||
* Validates all game moves and state transitions
|
||||
*/
|
||||
|
||||
import type { DifficultyLevel, SorobanQuizState } from '@/app/arcade/memory-quiz/types'
|
||||
import type {
|
||||
GameValidator,
|
||||
MemoryQuizGameMove,
|
||||
MemoryQuizSetConfigMove,
|
||||
ValidationResult,
|
||||
} from './types'
|
||||
|
||||
export class MemoryQuizGameValidator
|
||||
implements GameValidator<SorobanQuizState, MemoryQuizGameMove>
|
||||
{
|
||||
validateMove(
|
||||
state: SorobanQuizState,
|
||||
move: MemoryQuizGameMove,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_QUIZ':
|
||||
return this.validateStartQuiz(state, move.data)
|
||||
|
||||
case 'NEXT_CARD':
|
||||
return this.validateNextCard(state)
|
||||
|
||||
case 'SHOW_INPUT_PHASE':
|
||||
return this.validateShowInputPhase(state)
|
||||
|
||||
case 'ACCEPT_NUMBER':
|
||||
return this.validateAcceptNumber(state, move.data.number)
|
||||
|
||||
case 'REJECT_NUMBER':
|
||||
return this.validateRejectNumber(state)
|
||||
|
||||
case 'SET_INPUT':
|
||||
return this.validateSetInput(state, move.data.input)
|
||||
|
||||
case 'SHOW_RESULTS':
|
||||
return this.validateShowResults(state)
|
||||
|
||||
case 'RESET_QUIZ':
|
||||
return this.validateResetQuiz(state)
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
const configMove = move as MemoryQuizSetConfigMove
|
||||
return this.validateSetConfig(state, configMove.data.field, configMove.data.value)
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${(move as any).type}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartQuiz(state: SorobanQuizState, data: any): ValidationResult {
|
||||
// Can start quiz from setup or results phase
|
||||
if (state.gamePhase !== 'setup' && state.gamePhase !== 'results') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only start quiz from setup or results phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Accept either numbers array (from network) or quizCards (from client)
|
||||
const numbers = data.numbers || data.quizCards?.map((c: any) => c.number)
|
||||
|
||||
if (!numbers || numbers.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Quiz numbers are required',
|
||||
}
|
||||
}
|
||||
|
||||
// Create minimal quiz cards from numbers (server-side doesn't need React components)
|
||||
const quizCards = numbers.map((number: number) => ({
|
||||
number,
|
||||
svgComponent: null, // Not needed server-side
|
||||
element: null,
|
||||
}))
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
quizCards,
|
||||
correctAnswers: numbers,
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: numbers.length + Math.floor(numbers.length / 2),
|
||||
gamePhase: 'display',
|
||||
incorrectGuesses: 0,
|
||||
currentInput: '',
|
||||
wrongGuessAnimations: [],
|
||||
prefixAcceptanceTimeout: null,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateNextCard(state: SorobanQuizState): ValidationResult {
|
||||
// Must be in display phase
|
||||
if (state.gamePhase !== 'display') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'NEXT_CARD only valid in display phase',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
currentCardIndex: state.currentCardIndex + 1,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateShowInputPhase(state: SorobanQuizState): ValidationResult {
|
||||
// Must have shown all cards
|
||||
if (state.currentCardIndex < state.quizCards.length) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'All cards must be shown before input phase',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
gamePhase: 'input',
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateAcceptNumber(state: SorobanQuizState, number: number): ValidationResult {
|
||||
// Must be in input phase
|
||||
if (state.gamePhase !== 'input') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'ACCEPT_NUMBER only valid in input phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Number must be in correct answers
|
||||
console.log('[MemoryQuizValidator] Checking number:', {
|
||||
number,
|
||||
correctAnswers: state.correctAnswers,
|
||||
includes: state.correctAnswers.includes(number),
|
||||
})
|
||||
|
||||
if (!state.correctAnswers.includes(number)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Number is not a correct answer',
|
||||
}
|
||||
}
|
||||
|
||||
// Number must not be already found
|
||||
if (state.foundNumbers.includes(number)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Number already found',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
foundNumbers: [...state.foundNumbers, number],
|
||||
currentInput: '',
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateRejectNumber(state: SorobanQuizState): ValidationResult {
|
||||
// Must be in input phase
|
||||
if (state.gamePhase !== 'input') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'REJECT_NUMBER only valid in input phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Must have guesses remaining
|
||||
if (state.guessesRemaining <= 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No guesses remaining',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
guessesRemaining: state.guessesRemaining - 1,
|
||||
incorrectGuesses: state.incorrectGuesses + 1,
|
||||
currentInput: '',
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateSetInput(state: SorobanQuizState, input: string): ValidationResult {
|
||||
// Must be in input phase
|
||||
if (state.gamePhase !== 'input') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'SET_INPUT only valid in input phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Input must be numeric
|
||||
if (input && !/^\d+$/.test(input)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Input must be numeric',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
currentInput: input,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateShowResults(state: SorobanQuizState): ValidationResult {
|
||||
// Can show results from input phase
|
||||
if (state.gamePhase !== 'input') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'SHOW_RESULTS only valid from input phase',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateResetQuiz(state: SorobanQuizState): ValidationResult {
|
||||
// Can reset from any phase
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
quizCards: [],
|
||||
correctAnswers: [],
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: 0,
|
||||
currentInput: '',
|
||||
incorrectGuesses: 0,
|
||||
wrongGuessAnimations: [],
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: SorobanQuizState,
|
||||
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty',
|
||||
value: any
|
||||
): ValidationResult {
|
||||
// Can only change config during setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot change configuration outside of setup phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field-specific values
|
||||
switch (field) {
|
||||
case 'selectedCount':
|
||||
if (![2, 5, 8, 12, 15].includes(value)) {
|
||||
return { valid: false, error: `Invalid selectedCount: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'displayTime':
|
||||
if (typeof value !== 'number' || value < 0.5 || value > 10) {
|
||||
return { valid: false, error: `Invalid displayTime: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'selectedDifficulty':
|
||||
if (!['beginner', 'easy', 'medium', 'hard', 'expert'].includes(value)) {
|
||||
return { valid: false, error: `Invalid selectedDifficulty: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
return { valid: false, error: `Unknown config field: ${field}` }
|
||||
}
|
||||
|
||||
// Apply the configuration change
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
isGameComplete(state: SorobanQuizState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
getInitialState(config: {
|
||||
selectedCount: number
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
}): SorobanQuizState {
|
||||
return {
|
||||
cards: [],
|
||||
quizCards: [],
|
||||
correctAnswers: [],
|
||||
currentCardIndex: 0,
|
||||
displayTime: config.displayTime,
|
||||
selectedCount: config.selectedCount,
|
||||
selectedDifficulty: config.selectedDifficulty,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: 0,
|
||||
currentInput: '',
|
||||
incorrectGuesses: 0,
|
||||
gamePhase: 'setup',
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
wrongGuessAnimations: [],
|
||||
hasPhysicalKeyboard: null,
|
||||
testingMode: false,
|
||||
showOnScreenKeyboard: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const memoryQuizGameValidator = new MemoryQuizGameValidator()
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
|
||||
import { matchingGameValidator } from './MatchingGameValidator'
|
||||
import { memoryQuizGameValidator } from './MemoryQuizGameValidator'
|
||||
import type { GameName, GameValidator } from './types'
|
||||
|
||||
const validators = new Map<GameName, GameValidator>([
|
||||
['matching', matchingGameValidator],
|
||||
['memory-quiz', memoryQuizGameValidator],
|
||||
// Add other game validators here as they're implemented
|
||||
])
|
||||
|
||||
@@ -20,4 +22,5 @@ export function getValidator(gameName: GameName): GameValidator {
|
||||
}
|
||||
|
||||
export { matchingGameValidator } from './MatchingGameValidator'
|
||||
export { memoryQuizGameValidator } from './MemoryQuizGameValidator'
|
||||
export * from './types'
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { MemoryPairsState } from '@/app/games/matching/context/types'
|
||||
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
|
||||
|
||||
export type GameName = 'matching' | 'memory-quiz' | 'complement-race'
|
||||
|
||||
@@ -77,8 +78,74 @@ export type MatchingGameMove =
|
||||
| MatchingResumeGameMove
|
||||
| MatchingHoverCardMove
|
||||
|
||||
// Memory Quiz game specific moves
|
||||
export interface MemoryQuizStartQuizMove extends GameMove {
|
||||
type: 'START_QUIZ'
|
||||
data: {
|
||||
quizCards: any[] // QuizCard type from memory-quiz types
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizNextCardMove extends GameMove {
|
||||
type: 'NEXT_CARD'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizShowInputPhaseMove extends GameMove {
|
||||
type: 'SHOW_INPUT_PHASE'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizAcceptNumberMove extends GameMove {
|
||||
type: 'ACCEPT_NUMBER'
|
||||
data: {
|
||||
number: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizRejectNumberMove extends GameMove {
|
||||
type: 'REJECT_NUMBER'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizSetInputMove extends GameMove {
|
||||
type: 'SET_INPUT'
|
||||
data: {
|
||||
input: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizShowResultsMove extends GameMove {
|
||||
type: 'SHOW_RESULTS'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizResetQuizMove extends GameMove {
|
||||
type: 'RESET_QUIZ'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizSetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export type MemoryQuizGameMove =
|
||||
| MemoryQuizStartQuizMove
|
||||
| MemoryQuizNextCardMove
|
||||
| MemoryQuizShowInputPhaseMove
|
||||
| MemoryQuizAcceptNumberMove
|
||||
| MemoryQuizRejectNumberMove
|
||||
| MemoryQuizSetInputMove
|
||||
| MemoryQuizShowResultsMove
|
||||
| MemoryQuizResetQuizMove
|
||||
| MemoryQuizSetConfigMove
|
||||
|
||||
// Generic game state union
|
||||
export type GameState = MemoryPairsState // Add other game states as union later
|
||||
export type GameState = MemoryPairsState | SorobanQuizState // Add other game states as union later
|
||||
|
||||
/**
|
||||
* Validation context for authorization checks
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createRoom, getRoomById } from './lib/arcade/room-manager'
|
||||
import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager'
|
||||
import type { GameMove, GameName } from './lib/arcade/validation'
|
||||
import { matchingGameValidator } from './lib/arcade/validation/MatchingGameValidator'
|
||||
import { getValidator } from './lib/arcade/validation'
|
||||
|
||||
// Use globalThis to store socket.io instance to avoid module isolation issues
|
||||
// This ensures the same instance is accessible across dynamic imports
|
||||
@@ -76,12 +76,29 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
const roomPlayerIds = await getRoomPlayerIds(roomId)
|
||||
console.log('[join-arcade-session] Room active players:', roomPlayerIds)
|
||||
|
||||
// Get initial state from validator (starts in "setup" phase)
|
||||
const initialState = matchingGameValidator.getInitialState({
|
||||
difficulty: (room.gameConfig as any)?.difficulty || 6,
|
||||
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
|
||||
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
|
||||
})
|
||||
// Get initial state from the correct validator based on game type
|
||||
console.log('[join-arcade-session] Room game name:', room.gameName)
|
||||
const validator = getValidator(room.gameName as GameName)
|
||||
console.log('[join-arcade-session] Got validator for:', room.gameName)
|
||||
|
||||
// Different games have different initial configs
|
||||
let initialState: any
|
||||
if (room.gameName === 'matching') {
|
||||
initialState = validator.getInitialState({
|
||||
difficulty: (room.gameConfig as any)?.difficulty || 6,
|
||||
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
|
||||
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
|
||||
})
|
||||
} else if (room.gameName === 'memory-quiz') {
|
||||
initialState = validator.getInitialState({
|
||||
selectedCount: (room.gameConfig as any)?.selectedCount || 5,
|
||||
displayTime: (room.gameConfig as any)?.displayTime || 2.0,
|
||||
selectedDifficulty: (room.gameConfig as any)?.selectedDifficulty || 'easy',
|
||||
})
|
||||
} else {
|
||||
// Fallback for other games
|
||||
initialState = validator.getInitialState(room.gameConfig || {})
|
||||
}
|
||||
|
||||
session = await createArcadeSession({
|
||||
userId,
|
||||
@@ -162,8 +179,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get initial state from validator
|
||||
const initialState = matchingGameValidator.getInitialState({
|
||||
// Get initial state from validator (this code path is matching-game specific)
|
||||
const matchingValidator = getValidator('matching')
|
||||
const initialState = matchingValidator.getInitialState({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.13.5",
|
||||
"version": "3.14.4",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user