Compare commits

..

1 Commits

Author SHA1 Message Date
semantic-release-bot
963d9ec618 chore(release): 2.10.2 [skip ci]
## [2.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.10.2) (2025-10-09)

### Bug Fixes

* convert guestId to internal userId for player ownership check ([3a01f46](3a01f4637d))
2025-10-09 13:21:14 +00:00
55 changed files with 88 additions and 5814 deletions

View File

@@ -1,180 +1,9 @@
## [2.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.1...v2.17.2) (2025-10-10)
### Bug Fixes
* correct playerMetadata userId assignment for room-based multiplayer ([53797db](https://github.com/antialias/soroban-abacus-flashcards/commit/53797dbb2d5ccb80e61cbc186ca0a344fe1fbd96))
## [2.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.0...v2.17.1) (2025-10-10)
### Bug Fixes
* correct hover avatar and turn indicator to show only current player ([0596ef6](https://github.com/antialias/soroban-abacus-flashcards/commit/0596ef65879a303f1f71863ef307af69bf270c70))
## [2.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.7...v2.17.0) (2025-10-10)
### Features
* hide hover avatar when card is flipped to reveal value ([a2aada2](https://github.com/antialias/soroban-abacus-flashcards/commit/a2aada2e6922fb3af363e0d191275e06b8f8f040))
## [2.16.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.6...v2.16.7) (2025-10-10)
### Bug Fixes
* compile TypeScript server files to JavaScript for production ([83b9a4d](https://github.com/antialias/soroban-abacus-flashcards/commit/83b9a4d976fa540782826afa13a35c92e706bf1e))
* remove standalone output mode incompatible with custom server ([c8da5a8](https://github.com/antialias/soroban-abacus-flashcards/commit/c8da5a8340c8798bba452b43244bc0e04ce8b0c5))
* update Dockerfile for non-standalone production builds ([14746c5](https://github.com/antialias/soroban-abacus-flashcards/commit/14746c568e58f4a847e0da2d866dbaeabf5a0e8b))
## [2.16.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.5...v2.16.6) (2025-10-10)
### Bug Fixes
* correct static files and public path in Docker image ([c287b19](https://github.com/antialias/soroban-abacus-flashcards/commit/c287b19a39e1506033db6de39aa4d3761cb65d62))
## [2.16.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.4...v2.16.5) (2025-10-09)
### Bug Fixes
* correct node_modules path for pnpm symlinks in Docker ([c12351f](https://github.com/antialias/soroban-abacus-flashcards/commit/c12351f2c99daaed710a1136eb13f6ccc54cbcff))
## [2.16.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.3...v2.16.4) (2025-10-09)
### Bug Fixes
* correct Docker CMD to use root-level server.js ([48b47e9](https://github.com/antialias/soroban-abacus-flashcards/commit/48b47e9bdb0da44746282cd7cf7599a69bf5130d))
## [2.16.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.2...v2.16.3) (2025-10-09)
### Bug Fixes
* use game state playerMetadata instead of GameModeContext in UI components ([388c254](https://github.com/antialias/soroban-abacus-flashcards/commit/388c25451d11b85236c1f7682fe2f7a62a15d5eb))
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)
### Bug Fixes
* use only local user's players in LocalMemoryPairsProvider ([c26138f](https://github.com/antialias/soroban-abacus-flashcards/commit/c26138ffb55a237a99cb6ff399c8a2ac54a22b51))
## [2.16.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.0...v2.16.1) (2025-10-09)
### Bug Fixes
* convert LocalMemoryPairsProvider to pure client-side with useReducer ([b128db1](https://github.com/antialias/soroban-abacus-flashcards/commit/b128db1783a8dcffe7879745c3342add2f9ffe29))
## [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.15.0...v2.16.0) (2025-10-09)
### Features
* fade out hover avatar when player stops hovering ([820eeb4](https://github.com/antialias/soroban-abacus-flashcards/commit/820eeb4fb03ad8be6a86dd0a26e089052224f427))
## [2.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.3...v2.15.0) (2025-10-09)
### Features
* implement smooth hover avatar animations with react-spring ([442c6b4](https://github.com/antialias/soroban-abacus-flashcards/commit/442c6b4529ba5c820b1fe8a64805a3d85489a8ea))
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)
### Bug Fixes
* enable smooth spring animations between card hovers ([8d53b58](https://github.com/antialias/soroban-abacus-flashcards/commit/8d53b589aa17ebc6d0a9251b3006fd8a90f90a61))
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)
### Bug Fixes
* correct avatar positioning to prevent fly-in animation ([573d0df](https://github.com/antialias/soroban-abacus-flashcards/commit/573d0df20dcdac41021c46feb423dbf3782728f6))
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)
### Bug Fixes
* prevent avatar fly-in and hide local player's own hover ([7f65a67](https://github.com/antialias/soroban-abacus-flashcards/commit/7f65a67cef3d7f0ebce1bd7417972a6138acfc46))
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)
### Features
* improve hover avatars with smooth animation and 3D elevation ([71b11f4](https://github.com/antialias/soroban-abacus-flashcards/commit/71b11f4ef08a5f9c3f1c1aaabca21ef023d5c0ce))
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)
### Features
* implement networked hover presence for multiplayer gameplay ([62f3730](https://github.com/antialias/soroban-abacus-flashcards/commit/62f3730542334a0580f5dad1c73adc333614ee58))
### Code Refactoring
* move canModifyPlayers logic into provider layer ([db9f909](https://github.com/antialias/soroban-abacus-flashcards/commit/db9f9096b446b078e1b4dfe970723bef54a6f4ae))
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](https://github.com/antialias/soroban-abacus-flashcards/commit/98822ecda52bf004d9950e3f4c92c834fd820e49))
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)
### Bug Fixes
* always show game control buttons in room-based sessions ([14ba422](https://github.com/antialias/soroban-abacus-flashcards/commit/14ba422919abd648e2a134ce167a5e6fd9f84e73))
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
### Bug Fixes
* use RoomMemoryPairsProvider in room page ([c279731](https://github.com/antialias/soroban-abacus-flashcards/commit/c27973191f0144604e17a8a14adf0a88df476e27))
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)
### Bug Fixes
* export MemoryPairsContext to fix provider hook error ([80ad33e](https://github.com/antialias/soroban-abacus-flashcards/commit/80ad33eec0b6946702eaa9cf1b1c246852864b00))
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)
### Features
* add networked hover state infrastructure for multiplayer presence ([d149799](https://github.com/antialias/soroban-abacus-flashcards/commit/d14979907c5df9b793a1c110028fc5b54457f507))
## [2.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.11.0) (2025-10-09)
### Features
* add pause/resume game state architecture ([05eacac](https://github.com/antialias/soroban-abacus-flashcards/commit/05eacac438dbaf405ce91e188c53dbbe2e9f9507))
* add Resume button and config change warning to setup UI ([b5ee04f](https://github.com/antialias/soroban-abacus-flashcards/commit/b5ee04f57651f53517468fcc4c456f0ccb65a8e2))
* implement pause/resume in game providers with optimistic updates ([ce30fca](https://github.com/antialias/soroban-abacus-flashcards/commit/ce30fcaf55270f9089249bd13ba73a25fbfa5ab4))
### Bug Fixes
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
## [2.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.10.2) (2025-10-09)
### Bug Fixes
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
## [2.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.0...v2.10.1) (2025-10-09)

View File

@@ -34,44 +34,20 @@ RUN turbo build --filter=@soroban/web
FROM node:18-alpine AS runner
WORKDIR /app
# Install Python and build tools for better-sqlite3 (needed at runtime)
RUN apk add --no-cache python3 py3-setuptools make g++
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built Next.js application
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./apps/web/.next
# Copy built application
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
# Copy server files (compiled from TypeScript)
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/server.js ./apps/web/
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/socket-server.js ./apps/web/
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/src ./apps/web/src
# Copy database migrations
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
# Copy node_modules (for dependencies)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
# Copy package.json files for module resolution
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
# Set up environment
WORKDIR /app/apps/web
# Create data directory for SQLite database
RUN mkdir -p data && chown nextjs:nodejs data
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV production
# Start the application
CMD ["node", "server.js"]
CMD ["node", "apps/web/server.js"]

View File

@@ -1,492 +0,0 @@
# Arcade Game Architecture
## Overview
The arcade system supports two distinct game modes that must remain completely isolated from each other:
1. **Local Play** - Games without network synchronization (can be single-player OR local multiplayer)
2. **Room-Based Play** - Networked games with real-time synchronization across room members
## Core Terminology
Following `docs/terminology-user-player-room.md`:
- **USER** - Identity (guest or authenticated account), retrieved via `useViewerId()`, one per browser/account
- **PLAYER** - Game avatar/profile (e.g., "Alice 👧", "Bob 👦"), stored in `players` table
- **PLAYER ROSTER** - All PLAYERS belonging to a USER (can have many)
- **ACTIVE PLAYERS** - PLAYERS where `isActive = true` - these are the ones that actually participate in games
- **ROOM MEMBER** - A USER's participation in a multiplayer room (tracked in `room_members` table)
**Important:** A USER can have many PLAYERS in their roster, but only the ACTIVE PLAYERS (where `isActive = true`) participate in games. This enables "hot-potato" style local multiplayer where multiple people share the same device. This is LOCAL play (not networked), even though multiple PLAYERS participate.
In arcade sessions:
- `arcade_sessions.userId` - The USER who owns the session
- `arcade_sessions.activePlayers` - Array of PLAYER IDs (only active players with `isActive = true`)
- `arcade_sessions.roomId` - If present, the room ID for networked play (references `arcade_rooms.id`)
## Critical Architectural Requirements
### 1. Mode Isolation (MUST ENFORCE)
**Local Play** (`/arcade/[game-name]`)
- MUST NOT sync game state across the network
- MUST NOT use room data, even if the USER is currently a member of an active room
- MUST create isolated, per-USER game sessions
- Game state lives only in the current browser tab/session
- CAN have multiple ACTIVE PLAYERS from the same USER (local multiplayer / hot-potato)
- State is NOT shared across the network, only within the browser session
**Room-Based Play** (`/arcade/room`)
- MUST sync game state across all room members via network
- MUST use the USER's current active room
- MUST coordinate moves via server WebSocket
- Game state is shared across all ACTIVE PLAYERS from all USERS in the room
- When a PLAYER makes a move, all room members see it in real-time
- CAN ALSO have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
### 2. Room ID Usage Rules
```typescript
// ❌ WRONG: Always checking for room data
const { roomData } = useRoomData()
useArcadeSession({ roomId: roomData?.id }) // This causes the bug!
// ✅ CORRECT: Explicit mode control via separate providers
<LocalMemoryPairsProvider> {/* Never passes roomId */}
<RoomMemoryPairsProvider> {/* Always passes roomId */}
```
**Key principle:** The presence of a `roomId` parameter in `useArcadeSession` determines synchronization behavior:
- `roomId` present → room-wide network sync enabled (room-based play)
- `roomId` undefined → local play only (no network sync)
### 3. Composition Over Flags (PREFERRED APPROACH)
**✅ Option 1: Separate Providers (CLEAREST - USE THIS)**
Create two distinct provider components:
```typescript
// context/LocalMemoryPairsProvider.tsx
export function LocalMemoryPairsProvider({ children }) {
const { data: viewerId } = useViewerId()
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
// NEVER fetch room data for local play
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: undefined, // Explicitly undefined - no network sync
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest of provider logic
// Note: activePlayers contains only PLAYERS with isActive = true
}
// context/RoomMemoryPairsProvider.tsx
export function RoomMemoryPairsProvider({ children }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // OK to fetch for room-based play
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // Pass roomId for network sync
initialState,
applyMove: applyMoveOptimistically,
})
// ... rest of provider logic
}
```
Then use them explicitly:
```typescript
// /arcade/matching/page.tsx (Local Play)
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<LocalMemoryPairsProvider>
<MemoryPairsGame />
</LocalMemoryPairsProvider>
</ArcadeGuardedPage>
)
}
// /arcade/room/page.tsx (Room-Based Play)
export default function RoomPage() {
// ... room validation logic
if (roomData.gameName === 'matching') {
return (
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
}
}
```
**Benefits of separate providers:**
- Compile-time safety - impossible to mix modes
- Clear intent - any developer can see which mode at a glance
- No runtime conditionals needed
- Easier to test - each provider tests separately
**❌ Avoid:** Runtime flag checking scattered throughout code
```typescript
// Anti-pattern: Too many conditionals
if (isRoomBased) { ... } else { ... }
```
### 4. How Synchronization Works
#### Local Play Flow
```
USER Action → useArcadeSession (roomId: undefined)
→ WebSocket emit('join-arcade-session', { userId })
→ Server creates isolated session for userId
→ Session key = userId
→ session.activePlayers = USER's active player IDs (isActive = true)
→ State changes only affect this USER's browser tabs
Note: Multiple ACTIVE PLAYERS from same USER can participate (local multiplayer),
but state is NEVER synced across network
```
#### Room-Based Play Flow
```
USER Action (on behalf of PLAYER)
→ useArcadeSession (roomId: 'room_xyz')
→ WebSocket emit('join-arcade-session', { userId, roomId })
→ Server creates/joins shared session for roomId
→ session.activePlayers = ALL active players from ALL room members
→ Socket joins TWO rooms: `arcade:${userId}` AND `game:${roomId}`
→ PLAYER makes move
→ Server validates PLAYER ownership (is this PLAYER owned by this USER?)
→ State changes broadcast to:
- arcade:${userId} - All tabs of this USER (for optimistic reconciliation)
- game:${roomId} - All USERS in the room (for network sync)
Note: Each USER can still have multiple ACTIVE PLAYERS (local + networked multiplayer)
```
The server-side logic uses `roomId` to determine session scope:
- No `roomId`: Session key = `userId` (isolated to USER's browser)
- With `roomId`: Session key = `roomId` (shared across all room members)
See `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` for detailed socket room mechanics.
### 5. USER vs PLAYER in Game Logic
**Important distinction:**
- **Session ownership**: Tracked by USER ID (`useViewerId()`)
- **Player roster**: All PLAYERS for a USER (can be many)
- **Active players**: PLAYERS with `isActive = true` (these join the game)
- **Game actions**: Performed by PLAYER ID (from `players` table)
- **Move validation**: Server checks that PLAYER ID belongs to the requesting USER
- **Local multiplayer**: One USER with multiple ACTIVE PLAYERS (hot-potato style, same device)
- **Networked multiplayer**: Multiple USERS, each with their own ACTIVE PLAYERS, in a room
```typescript
// ✅ Correct: USER owns session, ACTIVE PLAYERS participate
const { data: viewerId } = useViewerId() // USER ID
const { activePlayers } = useGameMode() // ACTIVE PLAYER IDs (isActive = true)
// activePlayers might be [player_001, player_002]
// even though USER has 5 total PLAYERS in their roster
const { state, sendMove } = useArcadeSession({
userId: viewerId, // Session owned by USER
roomId: undefined, // Local play (or roomData?.id for room-based)
// ...
})
// When PLAYER flips card:
sendMove({
type: 'FLIP_CARD',
playerId: currentPlayerId, // PLAYER ID from activePlayers
data: { cardId: '...' }
})
```
**Example Scenarios:**
1. **Single-player local game:**
- USER: "guest_abc"
- Player roster: ["player_001" (active), "player_002" (inactive), "player_003" (inactive)]
- Active PLAYERS in game: ["player_001"]
- Mode: Local play (no roomId)
2. **Local multiplayer (hot-potato):**
- USER: "guest_abc"
- Player roster: ["player_001" (active), "player_002" (active), "player_003" (active), "player_004" (inactive)]
- Active PLAYERS in game: ["player_001", "player_002", "player_003"] (3 kids sharing device)
- Mode: Local play (no roomId)
- Game rotates turns between the 3 active PLAYERS, but NO network sync
3. **Room-based networked play:**
- USER A: "guest_abc"
- Player roster: 5 total PLAYERS
- Active PLAYERS: ["player_001", "player_002"]
- USER B: "guest_def"
- Player roster: 3 total PLAYERS
- Active PLAYERS: ["player_003"]
- Mode: Room-based play (roomId: "room_xyz")
- Total PLAYERS in game: 3 (player_001, player_002, player_003)
- All 3 synced across network
4. **Room-based + local multiplayer combined:**
- USER A: "guest_abc" with 3 active PLAYERS (3 kids at Device A)
- USER B: "guest_def" with 2 active PLAYERS (2 kids at Device B)
- Mode: Room-based play (roomId: "room_xyz")
- 5 total active PLAYERS across 2 devices, all synced over network
## Common Mistakes to Avoid
### Mistake 1: Conditional Room Usage
```typescript
// ❌ BAD: Room sync leaks into local play
const { roomData } = useRoomData()
useArcadeSession({
roomId: roomData?.id // Local play will sync if USER is in a room!
})
```
### Mistake 2: Shared Components Without Mode Context
```typescript
// ❌ BAD: Same provider used for both modes
export default function LocalGamePage() {
return <GameProvider><Game /></GameProvider> // Which mode?
}
```
### Mistake 3: Confusing "multiplayer" with "networked"
```typescript
// ❌ BAD: Thinking multiple PLAYERS means room-based
if (activePlayers.length > 1) {
// Must be room-based! WRONG!
// Could be local multiplayer (hot-potato style)
}
// ✅ CORRECT: Check for roomId to determine network sync
const isNetworked = !!roomId
const isLocalMultiplayer = activePlayers.length > 1 && !roomId
```
### Mistake 4: Using all PLAYERS instead of only active ones
```typescript
// ❌ BAD: Including inactive players
const allPlayers = await db.query.players.findMany({
where: eq(players.userId, userId)
})
// ✅ CORRECT: Only active players join the game
const activePlayers = await db.query.players.findMany({
where: and(
eq(players.userId, userId),
eq(players.isActive, true)
)
})
```
### Mistake 5: Mixing USER ID and PLAYER ID
```typescript
// ❌ BAD: Using USER ID for game actions
sendMove({
type: 'FLIP_CARD',
playerId: viewerId, // WRONG! viewerId is USER ID, not PLAYER ID
data: { cardId: '...' }
})
// ✅ CORRECT: Use PLAYER ID from game state
sendMove({
type: 'FLIP_CARD',
playerId: state.currentPlayer, // PLAYER ID from activePlayers
data: { cardId: '...' }
})
```
### Mistake 6: Server-Side Ambiguity
```typescript
// ❌ BAD: Server can't distinguish intent
socket.on('join-arcade-session', ({ userId, roomId }) => {
// If roomId exists, did USER want local or room-based play?
// This happens when provider always passes roomData?.id
})
```
## Testing Requirements
Tests MUST verify mode isolation:
### Local Play Tests
```typescript
it('should NOT sync state when USER is in a room but playing locally', async () => {
// Setup: USER is a member of an active room
// Action: USER navigates to /arcade/matching
// Assert: Game state is NOT shared with other room members
// Assert: Other room members' actions do NOT affect this game
})
it('should create isolated sessions for concurrent local games', () => {
// Setup: Two USERS who are members of the same room
// Action: Both navigate to /arcade/matching separately
// Assert: Each has independent game state
// Assert: USER A's moves do NOT appear in USER B's game
})
it('should support local multiplayer without network sync', () => {
// Setup: USER with 3 active PLAYERS in roster (hot-potato style)
// Action: USER plays at /arcade/matching with the 3 active PLAYERS
// Assert: All 3 active PLAYERS participate in the same session
// Assert: Inactive PLAYERS do NOT participate
// Assert: State is NOT synced across network
// Assert: Game rotates turns between active PLAYERS locally
})
it('should only include active players in game', () => {
// Setup: USER has 5 PLAYERS in roster, but only 2 are active
// Action: USER starts a local game
// Assert: Only the 2 active PLAYERS are in activePlayers array
// Assert: Inactive PLAYERS are not included
})
it('should sync across USER tabs but not across network', () => {
// Setup: USER opens /arcade/matching in 2 browser tabs
// Action: PLAYER makes move in Tab 1
// Assert: Tab 2 sees the move (multi-tab sync)
// Assert: Other USERS do NOT see the move (no network sync)
})
```
### Room-Based Play Tests
```typescript
it('should sync state across all room members', async () => {
// Setup: Two USERS are members of the same room
// Action: USER A's PLAYER flips card at /arcade/room
// Assert: USER B sees the card flip in real-time
})
it('should sync across multiple active PLAYERS from multiple USERS', () => {
// Setup: USER A has 2 active PLAYERS, USER B has 1 active PLAYER in same room
// Action: USER A's PLAYER 1 makes move
// Assert: All 3 PLAYERS see the move (networked)
})
it('should only include active players in room games', () => {
// Setup: USER A (5 PLAYERS, 2 active), USER B (3 PLAYERS, 1 active) join room
// Action: Game starts
// Assert: session.activePlayers = [userA_player1, userA_player2, userB_player1]
// Assert: Inactive PLAYERS are NOT included
})
it('should handle combined local + networked multiplayer', () => {
// Setup: USER A (3 active PLAYERS), USER B (2 active PLAYERS) in same room
// Action: Any PLAYER makes a move
// Assert: All 5 active PLAYERS see the move across both devices
})
it('should fail gracefully when no room exists', () => {
// Setup: USER is not a member of any room
// Action: Navigate to /arcade/room
// Assert: Shows "No active room" message
// Assert: Does not create a session
})
it('should validate PLAYER ownership', async () => {
// Setup: USER A in room with active PLAYER 'alice'
// Action: USER A attempts move for PLAYER 'bob' (owned by USER B)
// Assert: Server rejects the move
// Assert: Error indicates unauthorized PLAYER
})
```
## Implementation Checklist
When adding a new game or modifying existing ones:
- [ ] Create separate `LocalGameProvider` and `RoomGameProvider` components
- [ ] Local provider never calls `useRoomData()`
- [ ] Local provider passes `roomId: undefined` to `useArcadeSession`
- [ ] Room provider calls `useRoomData()` and passes `roomId: roomData?.id`
- [ ] Both providers use `useGameMode()` to get active players
- [ ] Local play page uses `LocalGameProvider`
- [ ] `/arcade/room` page uses `RoomGameProvider`
- [ ] Game components correctly use PLAYER IDs (not USER IDs) for moves
- [ ] Game supports multiple active PLAYERS from same USER (local multiplayer)
- [ ] Inactive PLAYERS are never included in game sessions
- [ ] Tests verify mode isolation (local doesn't network sync, room-based does)
- [ ] Tests verify PLAYER ownership validation
- [ ] Tests verify only active PLAYERS participate
- [ ] Tests verify local multiplayer works (multiple active PLAYERS, one USER)
- [ ] Documentation updated if behavior changes
## File Organization
```
src/app/arcade/
├── [game-name]/ # Local play games
│ ├── page.tsx # Uses LocalGameProvider
│ └── context/
│ ├── LocalGameProvider.tsx # roomId: undefined
│ └── RoomGameProvider.tsx # roomId: roomData?.id
├── room/ # Room-based play
│ └── page.tsx # Uses RoomGameProvider
└── ...
```
## Architecture Decision Records
### Why separate providers instead of auto-detect from route?
While we could detect mode based on the route (`/arcade/room` vs `/arcade/matching`), separate providers are clearer and prevent accidental misuse. Future developers can immediately see the intent, and the type system can enforce correctness.
### Why being in a room doesn't mean all games sync?
A USER being a room member does NOT mean all their games should network sync. They should be able to play local games while remaining in a room for future room-based sessions. Mode is determined by the page they're on, not their room membership status.
### Why not use a single shared provider with mode props?
We tried that. It led to the current bug where local play accidentally synced with rooms. Separate providers make the distinction compile-time safe rather than runtime conditional, and eliminate the possibility of accidentally passing `roomId` when we shouldn't.
### Why do we track sessions by USER but moves by PLAYER?
- **Sessions** are per-USER because each USER can have their own game session
- **Moves** are per-PLAYER because PLAYERS are the game avatars that score points
- **Only active PLAYERS** (isActive = true) participate in games
- This allows:
- One USER with multiple active PLAYERS (local multiplayer / hot-potato)
- Multiple USERS in one room (networked play)
- Combined: Multiple USERS each with multiple active PLAYERS (local + networked)
- Proper ownership validation (server checks USER owns PLAYER)
- PLAYERS can be toggled active/inactive without deleting them
### Why use "local" vs "room-based" instead of "solo" vs "multiplayer"?
- **"Solo"** is misleading - a USER can have multiple active PLAYERS in local play (hot-potato style)
- **"Multiplayer"** is ambiguous - it could mean local multiplayer OR networked multiplayer
- **"Local play"** clearly means: no network sync (but can have multiple active PLAYERS)
- **"Room-based play"** clearly means: network sync across room members
## Related Files
- `src/hooks/useArcadeSession.ts` - Session management with optional roomId
- `src/hooks/useArcadeSocket.ts` - WebSocket connection with sync logic (socket rooms: `arcade:${userId}` and `game:${roomId}`)
- `src/hooks/useRoomData.ts` - Fetches USER's current room membership
- `src/hooks/useViewerId.ts` - Retrieves current USER ID
- `src/contexts/GameModeContext.tsx` - Provides active PLAYER information
- `src/app/arcade/matching/context/ArcadeMemoryPairsContext.tsx` - Game context (needs refactoring to separate providers)
- `src/app/arcade/matching/page.tsx` - Local play entry point
- `src/app/arcade/room/page.tsx` - Room-based play entry point
- `docs/terminology-user-player-room.md` - Terminology guide (USER/PLAYER/MEMBER)
- `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` - Technical details of room-based sync
## Version History
- **2025-10-09**: Initial documentation
- Issue identified: Local play was syncing with rooms over network
- Root cause: Same provider always fetched `roomData` and passed `roomId` to `useArcadeSession`
- Solution: Separate providers for local vs room-based play
- Terminology clarification: "local" vs "room-based" (not "solo" vs "multiplayer")
- Active players: Only PLAYERS with `isActive = true` participate in games

View File

@@ -1,416 +0,0 @@
# Arcade Setup Pattern
## Overview
This document describes the **standard synchronized setup pattern** for arcade games. Following this pattern ensures that:
1.**Setup is synchronized** - All room members see the same setup screen and config changes in real-time
2.**No local state hacks** - Configuration lives entirely in session state, no React state merging
3.**Optimistic updates** - Config changes feel instant with client-side prediction
4.**Consistent pattern** - All games follow the same architecture
**Reference Implementation**: `src/app/arcade/matching/*` (Matching game)
---
## Core Concept
Setup configuration is **game state**, not UI state. Configuration changes are **moves** that are validated, synchronized, and can be made by any room member.
### Key Principles
1. **Game state includes configuration** during ALL phases (setup, playing, results)
2. **No local React state** for configuration - use session state directly
3. **Standard move types** that all games should implement
4. **Setup phase is collaborative** - any room member can configure the game
---
## Required Move Types
Every arcade game must support these standard moves:
### 1. `GO_TO_SETUP`
Transitions game to setup phase, allowing reconfiguration.
```typescript
{
type: 'GO_TO_SETUP',
playerId: string,
data: {}
}
```
**Behavior:**
- Can be called from any phase (setup, playing, results)
- Sets `gamePhase: 'setup'`
- Resets game progression (scores, cards, etc.)
- Preserves configuration (players can modify it)
- Synchronized across all room members
### 2. `SET_CONFIG`
Updates a configuration field during setup phase.
```typescript
{
type: 'SET_CONFIG',
playerId: string,
data: {
field: string, // Config field name
value: any // New value
}
}
```
**Behavior:**
- Only allowed during setup phase
- Validates field name and value
- Updates immediately with optimistic update
- Synchronized across all room members
### 3. `START_GAME`
Starts the game with current configuration.
```typescript
{
type: 'START_GAME',
playerId: string,
data: {
activePlayers: string[],
playerMetadata: { [playerId: string]: PlayerMetadata },
// ... game-specific initial data
}
}
```
**Behavior:**
- Only allowed during setup phase
- Uses current session state configuration
- Initializes game-specific state
- Sets `gamePhase: 'playing'`
---
## Implementation Checklist
### 1. Update Validation Types
Add move types to your game's validation types:
```typescript
// src/lib/arcade/validation/types.ts
export interface YourGameGoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface YourGameSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'configField1' | 'configField2' | 'configField3'
value: any
}
}
export type YourGameMove =
| YourGameStartGameMove
| YourGameGoToSetupMove
| YourGameSetConfigMove
| ... // other game-specific moves
```
### 2. Implement Validators
Add validators for setup moves in your game's validator class:
```typescript
// src/lib/arcade/validation/YourGameValidator.ts
export class YourGameValidator implements GameValidator<YourGameState, YourGameMove> {
validateMove(state, move, context) {
switch (move.type) {
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'START_GAME':
return this.validateStartGame(state, move.data)
// ... other moves
}
}
private validateGoToSetup(state: YourGameState): ValidationResult {
return {
valid: true,
newState: {
...state,
gamePhase: 'setup',
// Reset game progression, preserve configuration
// ... reset scores, game data, etc.
},
}
}
private validateSetConfig(
state: YourGameState,
field: string,
value: any
): ValidationResult {
// Only during setup
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Cannot change config outside setup' }
}
// Validate field-specific values
switch (field) {
case 'configField1':
if (!isValidValue(value)) {
return { valid: false, error: 'Invalid value' }
}
break
// ... validate other fields
}
return {
valid: true,
newState: { ...state, [field]: value },
}
}
private validateStartGame(state: YourGameState, data: any): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only start from setup' }
}
// Use current state configuration to initialize game
const initialGameData = initializeYourGame(state.configField1, state.configField2)
return {
valid: true,
newState: {
...state,
gamePhase: 'playing',
...initialGameData,
},
}
}
}
```
### 3. Add Optimistic Updates
Update `applyMoveOptimistically` in your providers:
```typescript
// src/app/arcade/your-game/context/YourGameProvider.tsx
function applyMoveOptimistically(state: YourGameState, move: GameMove): YourGameState {
switch (move.type) {
case 'GO_TO_SETUP':
return {
...state,
gamePhase: 'setup',
// Reset game state, preserve config
}
case 'SET_CONFIG':
const { field, value } = move.data
return {
...state,
[field]: value,
}
case 'START_GAME':
return {
...state,
gamePhase: 'playing',
// ... initialize game data from move
}
// ... other moves
}
}
```
### 4. Remove Local State from Providers
**❌ OLD PATTERN (Don't do this):**
```typescript
// DON'T: Local React state for configuration
const [localDifficulty, setLocalDifficulty] = useState(6)
// DON'T: Merge hack
const effectiveState = state.gamePhase === 'setup'
? { ...state, difficulty: localDifficulty }
: state
// DON'T: Direct setter
const setDifficulty = (value) => setLocalDifficulty(value)
```
**✅ NEW PATTERN (Do this):**
```typescript
// DO: Use session state directly
const { state, sendMove } = useArcadeSession(...)
// DO: Send move for config changes
const setDifficulty = useCallback((value) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'difficulty', value },
})
}, [activePlayers, sendMove])
// DO: Use state directly (no merging!)
const contextValue = { state: { ...state, gameMode }, ... }
```
### 5. Update Action Creators
All configuration actions should send moves:
```typescript
export function YourGameProvider({ children }) {
const { state, sendMove } = useArcadeSession(...)
const setConfigField = useCallback((value) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'configField', value },
})
}, [activePlayers, sendMove])
const goToSetup = useCallback(() => {
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
playerId,
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
const startGame = useCallback(() => {
// Use current session state config (not local state!)
const initialData = initializeGame(state.config1, state.config2)
const playerId = activePlayers[0]
sendMove({
type: 'START_GAME',
playerId,
data: {
...initialData,
activePlayers,
playerMetadata: capturePlayerMetadata(players, activePlayers),
},
})
}, [state.config1, state.config2, activePlayers, sendMove])
return <YourGameContext.Provider value={...} />
}
```
---
## Benefits of This Pattern
### 1. **Synchronized Setup**
- User A clicks "Setup" → All room members see setup screen
- User B changes difficulty → All room members see the change
- User A clicks "Start" → All room members start playing
### 2. **No Special Cases**
- Setup works like gameplay (moves + validation)
- No conditional logic based on phase
- No React state merging hacks
### 3. **Easy to Extend**
- New games copy the same pattern
- Well-documented and tested
- Consistent developer experience
### 4. **Optimistic Updates**
- Config changes feel instant
- Client-side prediction + server validation
- Rollback on validation failure
---
## Testing Checklist
When implementing this pattern, test these scenarios:
### Local Mode
- [ ] Click setup button during game → returns to setup
- [ ] Change config fields → updates immediately
- [ ] Start game → uses current config
### Room Mode (Multi-User)
- [ ] User A clicks setup → User B sees setup screen
- [ ] User A changes difficulty → User B sees change in real-time
- [ ] User B changes game type → User A sees change in real-time
- [ ] User A starts game → Both users see game with same config
### Edge Cases
- [ ] Change config rapidly → no race conditions
- [ ] User with 0 players can see/modify setup
- [ ] Setup → Play → Setup preserves last config
- [ ] Invalid config values are rejected by validator
---
## Migration Guide
If you have an existing game using local state, follow these steps:
### Step 1: Add Move Types
Add `GO_TO_SETUP` and `SET_CONFIG` to your validation types.
### Step 2: Implement Validators
Add validators for the new moves in your game validator class.
### Step 3: Add Optimistic Updates
Update `applyMoveOptimistically` to handle the new moves.
### Step 4: Remove Local State
1. Delete all `useState` calls for configuration
2. Delete the `effectiveState` merging logic
3. Update action creators to send moves instead
### Step 5: Test
Run through the testing checklist above.
---
## Reference Implementation
See the Matching game for a complete reference implementation:
- **Types**: `src/lib/arcade/validation/types.ts`
- **Validator**: `src/lib/arcade/validation/MatchingGameValidator.ts`
- **Provider**: `src/app/arcade/matching/context/RoomMemoryPairsProvider.tsx`
- **Optimistic Updates**: `applyMoveOptimistically` function in provider
Look for comments marked with:
- `// STANDARD ARCADE PATTERN: GO_TO_SETUP`
- `// STANDARD ARCADE PATTERN: SET_CONFIG`
- `// NO LOCAL STATE`
- `// NO MORE effectiveState merging!`
---
## Questions?
If something is unclear or you encounter issues implementing this pattern, refer to the Matching game implementation or update this document with clarifications.

View File

@@ -1,18 +1,6 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Read(//Users/antialias/projects/**)",
"Bash(npm run lint:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(npm run format:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run type-check:*)"
],
"allow": ["Bash(npm test:*)", "Read(//Users/antialias/projects/**)"],
"deny": [],
"ask": []
}

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
eslint: {
ignoreDuringBuilds: true,
},

View File

@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
"build": "node scripts/generate-build-info.js && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
"dev": "concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
"build": "node scripts/generate-build-info.js && next build",
"start": "NODE_ENV=production node server.js",
"lint": "npx @biomejs/biome lint . && npx eslint .",
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
@@ -90,7 +90,6 @@
"happy-dom": "^18.0.1",
"jsdom": "^27.0.0",
"storybook": "^9.1.7",
"tsc-alias": "^1.8.16",
"tsx": "^4.20.5",
"typescript": "^5.0.0",
"vitest": "^1.0.0"

View File

@@ -11,8 +11,9 @@ const handle = app.getRequestHandler()
// Run migrations before starting server
console.log('🔄 Running database migrations...')
require('tsx/cjs')
const { migrate } = require('drizzle-orm/better-sqlite3/migrator')
const { db } = require('./src/db/index.js')
const { db } = require('./src/db/index.ts')
try {
migrate(db, { migrationsFolder: './drizzle' })
@@ -34,8 +35,9 @@ app.prepare().then(() => {
}
})
// Initialize Socket.IO
const { initializeSocketServer } = require('./socket-server.js')
// Initialize Socket.IO (load TypeScript with tsx)
require('tsx/cjs')
const { initializeSocketServer } = require('./socket-server.ts')
initializeSocketServer(server)
server

View File

@@ -1,319 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getSocketIO = getSocketIO;
exports.initializeSocketServer = initializeSocketServer;
const socket_io_1 = require("socket.io");
const session_manager_1 = require("./src/lib/arcade/session-manager");
const room_manager_1 = require("./src/lib/arcade/room-manager");
const room_membership_1 = require("./src/lib/arcade/room-membership");
const player_manager_1 = require("./src/lib/arcade/player-manager");
const MatchingGameValidator_1 = require("./src/lib/arcade/validation/MatchingGameValidator");
/**
* Get the socket.io server instance
* Returns null if not initialized
*/
function getSocketIO() {
return globalThis.__socketIO || null;
}
function initializeSocketServer(httpServer) {
const io = new socket_io_1.Server(httpServer, {
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
credentials: true,
},
});
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id);
let currentUserId = null;
// Join arcade session room
socket.on('join-arcade-session', async ({ userId, roomId }) => {
currentUserId = userId;
socket.join(`arcade:${userId}`);
console.log(`👤 User ${userId} joined arcade room`);
// If this session is part of a room, also join the game room for multi-user sync
if (roomId) {
socket.join(`game:${roomId}`);
console.log(`🎮 User ${userId} joined game room ${roomId}`);
}
// Send current session state if exists
// For room-based games, look up shared room session
try {
const session = roomId
? await (0, session_manager_1.getArcadeSessionByRoom)(roomId)
: await (0, session_manager_1.getArcadeSession)(userId);
if (session) {
console.log('[join-arcade-session] Found session:', {
userId,
roomId,
version: session.version,
sessionUserId: session.userId,
});
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,
gameUrl: session.gameUrl,
activePlayers: session.activePlayers,
version: session.version,
});
}
else {
console.log('[join-arcade-session] No active session found for:', {
userId,
roomId,
});
socket.emit('no-active-session');
}
}
catch (error) {
console.error('Error fetching session:', error);
socket.emit('session-error', { error: 'Failed to fetch session' });
}
});
// Handle game moves
socket.on('game-move', async (data) => {
console.log('🎮 Game move received:', {
userId: data.userId,
moveType: data.move.type,
playerId: data.move.playerId,
timestamp: data.move.timestamp,
roomId: data.roomId,
fullMove: JSON.stringify(data.move, null, 2),
});
try {
// Special handling for START_GAME - create session if it doesn't exist
if (data.move.type === 'START_GAME') {
// For room-based games, check if room session exists
const existingSession = data.roomId
? await (0, session_manager_1.getArcadeSessionByRoom)(data.roomId)
: await (0, session_manager_1.getArcadeSession)(data.userId);
if (!existingSession) {
console.log('🎯 Creating new session for START_GAME');
// activePlayers must be provided in the START_GAME move data
const activePlayers = data.move.data?.activePlayers;
if (!activePlayers || activePlayers.length === 0) {
console.error('❌ START_GAME move missing activePlayers');
socket.emit('move-rejected', {
error: 'START_GAME requires at least one active player',
move: data.move,
});
return;
}
// Get initial state from validator
const initialState = MatchingGameValidator_1.matchingGameValidator.getInitialState({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
});
// Check if user is already in a room for this game
const userRoomIds = await (0, room_membership_1.getUserRooms)(data.userId);
let room = null;
// Look for an existing active room for this game
for (const roomId of userRoomIds) {
const existingRoom = await (0, room_manager_1.getRoomById)(roomId);
if (existingRoom &&
existingRoom.gameName === 'matching' &&
existingRoom.status !== 'finished') {
room = existingRoom;
console.log('🏠 Using existing room:', room.code);
break;
}
}
// If no suitable room exists, create a new one
if (!room) {
room = await (0, room_manager_1.createRoom)({
name: 'Auto-generated Room',
createdBy: data.userId,
creatorName: 'Player',
gameName: 'matching',
gameConfig: {
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
},
ttlMinutes: 60,
});
console.log('🏠 Created new room:', room.code);
}
// Now create the session linked to the room
await (0, session_manager_1.createArcadeSession)({
userId: data.userId,
gameName: 'matching',
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
initialState,
activePlayers,
roomId: room.id,
});
console.log('✅ Session created successfully with room association');
// Notify all connected clients about the new session
const newSession = await (0, session_manager_1.getArcadeSession)(data.userId);
if (newSession) {
io.to(`arcade:${data.userId}`).emit('session-state', {
gameState: newSession.gameState,
currentGame: newSession.currentGame,
gameUrl: newSession.gameUrl,
activePlayers: newSession.activePlayers,
version: newSession.version,
});
console.log('📢 Emitted session-state to notify clients of new session');
}
}
}
// Apply game move - use roomId for room-based games to access shared session
const result = await (0, session_manager_1.applyGameMove)(data.userId, data.move, data.roomId);
if (result.success && result.session) {
const moveAcceptedData = {
gameState: result.session.gameState,
version: result.session.version,
move: data.move,
};
// Broadcast the updated state to all devices for this user
io.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData);
// If this is a room-based session, ALSO broadcast to all users in the room
if (result.session.roomId) {
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData);
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`);
}
// Update activity timestamp
await (0, session_manager_1.updateSessionActivity)(data.userId);
}
else {
// Send rejection only to the requesting socket
socket.emit('move-rejected', {
error: result.error,
move: data.move,
versionConflict: result.versionConflict,
});
}
}
catch (error) {
console.error('Error processing move:', error);
socket.emit('move-rejected', {
error: 'Server error processing move',
move: data.move,
});
}
});
// Handle session exit
socket.on('exit-arcade-session', async ({ userId }) => {
console.log('🚪 User exiting arcade session:', userId);
try {
await (0, session_manager_1.deleteArcadeSession)(userId);
io.to(`arcade:${userId}`).emit('session-ended');
}
catch (error) {
console.error('Error ending session:', error);
socket.emit('session-error', { error: 'Failed to end session' });
}
});
// Keep-alive ping
socket.on('ping-session', async ({ userId }) => {
try {
await (0, session_manager_1.updateSessionActivity)(userId);
socket.emit('pong-session');
}
catch (error) {
console.error('Error updating activity:', error);
}
});
// Room: Join
socket.on('join-room', async ({ roomId, userId }) => {
console.log(`🏠 User ${userId} joining room ${roomId}`);
try {
// Join the socket room
socket.join(`room:${roomId}`);
// Mark member as online
await (0, room_membership_1.setMemberOnline)(roomId, userId, true);
// Get room data
const members = await (0, room_membership_1.getRoomMembers)(roomId);
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj = {};
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players;
}
// Send current room state to the joining user
socket.emit('room-joined', {
roomId,
members,
memberPlayers: memberPlayersObj,
});
// Notify all other members in the room
socket.to(`room:${roomId}`).emit('member-joined', {
roomId,
userId,
members,
memberPlayers: memberPlayersObj,
});
console.log(`✅ User ${userId} joined room ${roomId}`);
}
catch (error) {
console.error('Error joining room:', error);
socket.emit('room-error', { error: 'Failed to join room' });
}
});
// Room: Leave
socket.on('leave-room', async ({ roomId, userId }) => {
console.log(`🚪 User ${userId} leaving room ${roomId}`);
try {
// Leave the socket room
socket.leave(`room:${roomId}`);
// Mark member as offline
await (0, room_membership_1.setMemberOnline)(roomId, userId, false);
// Get updated members
const members = await (0, room_membership_1.getRoomMembers)(roomId);
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
// Convert memberPlayers Map to object
const memberPlayersObj = {};
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players;
}
// Notify remaining members
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId,
members,
memberPlayers: memberPlayersObj,
});
console.log(`✅ User ${userId} left room ${roomId}`);
}
catch (error) {
console.error('Error leaving room:', error);
}
});
// Room: Players updated
socket.on('players-updated', async ({ roomId, userId }) => {
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`);
try {
// Get updated player data
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
// Convert memberPlayers Map to object
const memberPlayersObj = {};
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players;
}
// Broadcast to all members in the room (including sender)
io.to(`room:${roomId}`).emit('room-players-updated', {
roomId,
memberPlayers: memberPlayersObj,
});
console.log(`✅ Broadcasted player updates for room ${roomId}`);
}
catch (error) {
console.error('Error updating room players:', error);
socket.emit('room-error', { error: 'Failed to update players' });
}
});
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id);
if (currentUserId) {
// Don't delete session on disconnect - it persists across devices
console.log(`👤 User ${currentUserId} disconnected but session persists`);
}
});
});
// Store in globalThis to make accessible across module boundaries
globalThis.__socketIO = io;
console.log('✅ Socket.IO initialized on /api/socket');
return io;
}

View File

@@ -6,7 +6,6 @@ import {
createArcadeSession,
deleteArcadeSession,
getArcadeSession,
getArcadeSessionByRoom,
updateSessionActivity,
} from './src/lib/arcade/session-manager'
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
@@ -57,19 +56,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
// Send current session state if exists
// For room-based games, look up shared room session
try {
const session = roomId
? await getArcadeSessionByRoom(roomId)
: await getArcadeSession(userId)
const session = await getArcadeSession(userId)
if (session) {
console.log('[join-arcade-session] Found session:', {
userId,
roomId,
version: session.version,
sessionUserId: session.userId,
})
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,
@@ -78,10 +67,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
version: session.version,
})
} else {
console.log('[join-arcade-session] No active session found for:', {
userId,
roomId,
})
socket.emit('no-active-session')
}
} catch (error) {
@@ -92,23 +77,19 @@ export function initializeSocketServer(httpServer: HTTPServer) {
)
// Handle game moves
socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => {
socket.on('game-move', async (data: { userId: string; move: GameMove }) => {
console.log('🎮 Game move received:', {
userId: data.userId,
moveType: data.move.type,
playerId: data.move.playerId,
timestamp: data.move.timestamp,
roomId: data.roomId,
fullMove: JSON.stringify(data.move, null, 2),
})
try {
// Special handling for START_GAME - create session if it doesn't exist
if (data.move.type === 'START_GAME') {
// For room-based games, check if room session exists
const existingSession = data.roomId
? await getArcadeSessionByRoom(data.roomId)
: await getArcadeSession(data.userId)
const existingSession = await getArcadeSession(data.userId)
if (!existingSession) {
console.log('🎯 Creating new session for START_GAME')
@@ -193,8 +174,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
}
// Apply game move - use roomId for room-based games to access shared session
const result = await applyGameMove(data.userId, data.move, data.roomId)
const result = await applyGameMove(data.userId, data.move)
if (result.success && result.session) {
const moveAcceptedData = {

View File

@@ -3,12 +3,12 @@
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { pluralizeWord } from '../../../../utils/pluralization'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { MemoryGrid } from './MemoryGrid'
import { PlayerStatusBar } from './PlayerStatusBar'
export function GamePhase() {
const { state, resetGame: _resetGame, activePlayers } = useMemoryPairs()
const { state, resetGame: _resetGame, activePlayers } = useArcadeMemoryPairs()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Convert Map to array and create mapping from numeric index to player

View File

@@ -1,10 +1,8 @@
'use client'
import { animated, useSpring } from '@react-spring/web'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useViewerId } from '@/hooks/useViewerId'
import { useEffect, useMemo, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { getGridConfiguration } from '../utils/cardGeneration'
import { GameCard } from './GameCard'
@@ -82,114 +80,8 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
return gridDimensions
}
// Animated hover avatar component
function HoverAvatar({
playerId,
playerInfo,
cardElement,
isPlayersTurn,
isCardFlipped,
}: {
playerId: string
playerInfo: { emoji: string; name: string; color?: string }
cardElement: HTMLElement | null
isPlayersTurn: boolean
isCardFlipped: boolean
}) {
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const isFirstRender = useRef(true)
// Update position when card element changes
useEffect(() => {
if (cardElement) {
const rect = cardElement.getBoundingClientRect()
// Calculate the center of the card for avatar positioning
const avatarCenterX = rect.left + rect.width / 2
const avatarCenterY = rect.top + rect.height / 2
setPosition({
x: avatarCenterX,
y: avatarCenterY,
})
}
}, [cardElement])
// Smooth spring animation for position changes
const springProps = useSpring({
x: position?.x ?? 0,
y: position?.y ?? 0,
// Hide avatar if: no position, not player's turn, no card element, OR card is flipped
opacity: position && isPlayersTurn && cardElement && !isCardFlipped ? 1 : 0,
config: {
tension: 280,
friction: 60,
mass: 1,
},
immediate: isFirstRender.current, // Skip animation on first render only
})
// Clear first render flag after initial render
useEffect(() => {
if (position && isFirstRender.current) {
isFirstRender.current = false
}
}, [position])
// Don't render until we have a position
if (!position) return null
return (
<animated.div
style={{
position: 'fixed',
// Don't use translate, just position directly at the calculated point
left: springProps.x.to((x) => `${x}px`),
top: springProps.y.to((y) => `${y}px`),
opacity: springProps.opacity,
width: '80px',
height: '80px',
marginLeft: '-40px', // Center horizontally (half of width)
marginTop: '-40px', // Center vertically (half of height)
borderRadius: '50%',
background: playerInfo.color || 'linear-gradient(135deg, #667eea, #764ba2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '48px',
// 3D elevation effect
boxShadow:
'0 12px 30px rgba(0,0,0,0.5), 0 6px 12px rgba(0,0,0,0.4), 0 0 40px rgba(102, 126, 234, 0.8)',
border: '4px solid white',
zIndex: 1000,
pointerEvents: 'none',
filter: 'drop-shadow(0 0 12px rgba(102, 126, 234, 0.9))',
}}
className={css({
animation: 'hoverFloat 2s ease-in-out infinite',
})}
title={`${playerInfo.name} is considering this card`}
>
{playerInfo.emoji}
</animated.div>
)
}
export function MemoryGrid() {
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
const { data: viewerId } = useViewerId()
// Track card element refs for positioning hover avatars
const cardRefs = useRef<Map<string, HTMLElement>>(new Map())
// Check if it's the local player's turn
const isMyTurn = useMemo(() => {
if (gameMode === 'single') return true // Always your turn in single player
// In local games, all players belong to current user, so always their turn
// In room games, check if current player belongs to this user
const currentPlayerMetadata = state.playerMetadata?.[state.currentPlayer]
return currentPlayerMetadata?.userId === viewerId
}, [state.currentPlayer, state.playerMetadata, viewerId, gameMode])
const { state, flipCard } = useArcadeMemoryPairs()
// Hooks must be called before early return
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
@@ -203,28 +95,6 @@ export function MemoryGrid() {
flipCard(cardId)
}
// Get player metadata for hover avatars
const getPlayerHoverInfo = (playerId: string) => {
// Get player info from game state metadata
const player = state.playerMetadata?.[playerId]
return player
? {
emoji: player.emoji,
name: player.name,
color: player.color,
}
: null
}
// Set card ref callback
const setCardRef = (cardId: string) => (element: HTMLDivElement | null) => {
if (element) {
cardRefs.current.set(cardId, element)
} else {
cardRefs.current.delete(cardId)
}
}
return (
<div
className={css({
@@ -292,7 +162,6 @@ export function MemoryGrid() {
return (
<div
key={card.id}
ref={setCardRef(card.id)}
className={css({
aspectRatio: '3/4',
// Fully responsive card sizing - no fixed pixel sizes
@@ -303,20 +172,7 @@ export function MemoryGrid() {
opacity: isDimmed ? 0.3 : 1,
transition: 'opacity 0.3s ease',
filter: isDimmed ? 'grayscale(0.7)' : 'none',
position: 'relative',
})}
onMouseEnter={() => {
// Only send hover if it's your turn and card is not matched
if (hoverCard && !isMatched && isMyTurn) {
hoverCard(card.id)
}
}}
onMouseLeave={() => {
// Clear hover state when mouse leaves card
if (hoverCard && !isMatched && isMyTurn) {
hoverCard(null)
}
}}
>
<GameCard
card={card}
@@ -377,68 +233,23 @@ export function MemoryGrid() {
})}
/>
)}
{/* Animated Hover Avatars - Rendered as fixed positioned elements that smoothly transition */}
{/* Render one avatar per player - key by playerId to keep component alive */}
{state.playerHovers &&
Object.entries(state.playerHovers)
.filter(([playerId]) => {
// Only show avatar for the CURRENT player whose turn it is
// Don't show for other players (they're waiting for their turn)
return playerId === state.currentPlayer
})
.map(([playerId, cardId]) => {
const playerInfo = getPlayerHoverInfo(playerId)
// Get card element if player is hovering (cardId might be null)
const cardElement = cardId ? cardRefs.current.get(cardId) : null
// Check if it's this player's turn
const isPlayersTurn = state.currentPlayer === playerId
// Check if the card being hovered is flipped
const hoveredCard = cardId ? state.gameCards.find((c) => c.id === cardId) : null
const isCardFlipped = hoveredCard
? state.flippedCards.some((c) => c.id === hoveredCard.id) || hoveredCard.matched
: false
if (!playerInfo) return null
// Render avatar even if no cardElement (it will handle hiding itself)
return (
<HoverAvatar
key={playerId} // Key by playerId keeps component alive across card changes!
playerId={playerId}
playerInfo={playerInfo}
cardElement={cardElement}
isPlayersTurn={isPlayersTurn}
isCardFlipped={isCardFlipped}
/>
)
})}
</div>
)
}
// Add animations for mismatch feedback and hover avatars
const gridAnimations = `
// Add shake animation for mismatch feedback
const shakeAnimation = `
@keyframes shake {
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
25% { transform: translate(-50%, -50%) translateX(-5px); }
75% { transform: translate(-50%, -50%) translateX(5px); }
}
@keyframes hoverFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-6px);
}
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('memory-grid-animations')) {
const style = document.createElement('style')
style.id = 'memory-grid-animations'
style.textContent = gridAnimations
style.textContent = shakeAnimation
document.head.appendChild(style)
}

View File

@@ -3,18 +3,20 @@
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { css } from '../../../../../styled-system/css'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { useFullscreen } from '../../../../contexts/FullscreenContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { GamePhase } from './GamePhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function MemoryPairsGame() {
const router = useRouter()
const { state, exitSession, resetGame, goToSetup, canModifyPlayers } = useMemoryPairs()
const { state, exitSession, resetGame } = useArcadeMemoryPairs()
const { setFullscreenElement } = useFullscreen()
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
const gameRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -35,14 +37,11 @@ export function MemoryPairsGame() {
exitSession()
router.push('/arcade')
}}
onSetup={
goToSetup
? () => {
// Transition to setup phase (will pause game if active)
goToSetup()
}
: undefined
}
onSetup={() => {
// Exit current session and return to arcade (which will redirect to setup)
exitSession()
router.push('/arcade/matching')
}}
onNewGame={() => {
resetGame()
}}

View File

@@ -1,22 +1,21 @@
'use client'
import { useViewerId } from '@/hooks/useViewerId'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { gamePlurals } from '../../../../utils/pluralization'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
interface PlayerStatusBarProps {
className?: string
}
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
const { state } = useMemoryPairs()
const { data: viewerId } = useViewerId()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const { state } = useArcadeMemoryPairs()
// Get active players from game state (not GameModeContext)
// This ensures we only show players actually in this game
const activePlayersData = state.activePlayers
.map((id) => state.playerMetadata?.[id])
// Get active players array
const activePlayersData = Array.from(activePlayerIds)
.map((id) => playerMap.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
// Map active players to display data with scores
@@ -27,8 +26,7 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
displayEmoji: player.emoji,
score: state.scores[player.id] || 0,
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
// Check if this player belongs to the current viewer
isLocalPlayer: player.userId === viewerId,
isLocalPlayer: player.isLocal !== false, // Local if not explicitly marked as remote
}))
// Check if current player is local (your turn) or remote (waiting)

View File

@@ -3,12 +3,12 @@
import { useRouter } from 'next/navigation'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMemoryPairs()
const { state, resetGame, activePlayers, gameMode } = useArcadeMemoryPairs()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// Get active player data array

View File

@@ -1,9 +1,8 @@
'use client'
import { useState } from 'react'
import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
// Add bounce animation for the start button
const bounceAnimation = `
@@ -33,87 +32,14 @@ export function SetupPhase() {
state,
setGameType,
setDifficulty,
setTurnTimer,
startGame,
resumeGame,
canResumeGame,
hasConfigChanged,
activePlayers: _activePlayers,
} = useMemoryPairs()
} = useArcadeMemoryPairs()
const { activePlayerCount, gameMode: _globalGameMode } = useGameMode()
// PAUSE/RESUME: Warning dialog state
const [showConfigWarning, setShowConfigWarning] = useState(false)
const [hasSeenWarning, setHasSeenWarning] = useState(false)
const [pendingConfigChange, setPendingConfigChange] = useState<{
type: 'gameType' | 'difficulty' | 'turnTimer'
value: any
} | null>(null)
// Check if we should show warning when changing config
const shouldShowWarning = state.pausedGamePhase && !hasSeenWarning && !hasConfigChanged
// Config change handlers that check for paused game
const handleSetGameType = (value: typeof state.gameType) => {
if (shouldShowWarning) {
setPendingConfigChange({ type: 'gameType', value })
setShowConfigWarning(true)
} else {
setGameType(value)
}
}
const handleSetDifficulty = (value: typeof state.difficulty) => {
if (shouldShowWarning) {
setPendingConfigChange({ type: 'difficulty', value })
setShowConfigWarning(true)
} else {
setDifficulty(value)
}
}
const handleSetTurnTimer = (value: typeof state.turnTimer) => {
if (shouldShowWarning) {
setPendingConfigChange({ type: 'turnTimer', value })
setShowConfigWarning(true)
} else {
setTurnTimer(value)
}
}
// Apply pending config change after warning
const applyPendingChange = () => {
if (pendingConfigChange) {
switch (pendingConfigChange.type) {
case 'gameType':
setGameType(pendingConfigChange.value)
break
case 'difficulty':
setDifficulty(pendingConfigChange.value)
break
case 'turnTimer':
setTurnTimer(pendingConfigChange.value)
break
}
setHasSeenWarning(true)
setPendingConfigChange(null)
setShowConfigWarning(false)
}
}
// Cancel config change
const cancelConfigChange = () => {
setPendingConfigChange(null)
setShowConfigWarning(false)
}
const handleStartOrResumeGame = () => {
if (canResumeGame) {
resumeGame()
} else {
startGame()
}
const handleStartGame = () => {
startGame()
}
const getButtonStyles = (
@@ -224,94 +150,6 @@ export function SetupPhase() {
minHeight: 0, // Allow shrinking
})}
>
{/* PAUSE/RESUME: Config change warning */}
{showConfigWarning && (
<div
className={css({
p: '4',
background:
'linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(245, 158, 11, 0.15))',
border: '2px solid',
borderColor: 'yellow.400',
rounded: 'xl',
textAlign: 'center',
boxShadow: '0 4px 12px rgba(251, 191, 36, 0.2)',
})}
>
<p
className={css({
color: 'yellow.700',
fontSize: { base: '15px', md: '17px' },
fontWeight: 'bold',
marginBottom: '8px',
})}
>
Warning: Changing Settings Will End Current Game
</p>
<p
className={css({
color: 'gray.600',
fontSize: { base: '13px', md: '14px' },
marginBottom: '12px',
})}
>
You have a paused game in progress. Changing any setting will end it and you won't be
able to resume.
</p>
<div
className={css({
display: 'flex',
gap: '8px',
justifyContent: 'center',
flexWrap: 'wrap',
})}
>
<button
className={css({
background: 'linear-gradient(135deg, #10b981, #059669)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.4)',
},
})}
onClick={cancelConfigChange}
>
✓ Keep Game & Cancel Change
</button>
<button
className={css({
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '8px 16px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.3)',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.4)',
},
})}
onClick={applyPendingChange}
>
✗ End Game & Apply Change
</button>
</div>
</div>
)}
{/* Warning if no players */}
{activePlayerCount === 0 && (
<div
@@ -362,7 +200,7 @@ export function SetupPhase() {
>
<button
className={getButtonStyles(state.gameType === 'abacus-numeral', 'secondary')}
onClick={() => handleSetGameType('abacus-numeral')}
onClick={() => setGameType('abacus-numeral')}
>
<div
className={css({
@@ -408,7 +246,7 @@ export function SetupPhase() {
</button>
<button
className={getButtonStyles(state.gameType === 'complement-pairs', 'secondary')}
onClick={() => handleSetGameType('complement-pairs')}
onClick={() => setGameType('complement-pairs')}
>
<div
className={css({
@@ -504,7 +342,7 @@ export function SetupPhase() {
<button
key={difficulty}
className={getButtonStyles(state.difficulty === difficulty, 'difficulty')}
onClick={() => handleSetDifficulty(difficulty)}
onClick={() => setDifficulty(difficulty)}
>
<div
className={css({
@@ -576,7 +414,7 @@ export function SetupPhase() {
<button
key={timer}
className={getButtonStyles(state.turnTimer === timer, 'secondary')}
onClick={() => handleSetTurnTimer(timer)}
onClick={() => dispatch({ type: 'SET_TURN_TIMER', timer })}
>
<div
className={css({
@@ -626,9 +464,7 @@ export function SetupPhase() {
>
<button
className={css({
background: canResumeGame
? 'linear-gradient(135deg, #10b981 0%, #059669 50%, #34d399 100%)'
: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
color: 'white',
border: 'none',
borderRadius: { base: '16px', sm: '20px', md: '24px' },
@@ -637,9 +473,7 @@ export function SetupPhase() {
fontWeight: 'black',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: canResumeGame
? '0 8px 20px rgba(16, 185, 129, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)'
: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
@@ -657,12 +491,9 @@ export function SetupPhase() {
},
_hover: {
transform: { base: 'translateY(-2px)', md: 'translateY(-3px) scale(1.02)' },
boxShadow: canResumeGame
? '0 12px 30px rgba(16, 185, 129, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)'
: '0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
background: canResumeGame
? 'linear-gradient(135deg, #059669 0%, #047857 50%, #10b981 100%)'
: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
boxShadow:
'0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
_before: {
left: '100%',
},
@@ -671,7 +502,7 @@ export function SetupPhase() {
transform: 'translateY(-1px) scale(1.01)',
},
})}
onClick={handleStartOrResumeGame}
onClick={handleStartGame}
>
<div
className={css({
@@ -687,9 +518,9 @@ export function SetupPhase() {
animation: 'bounce 2s infinite',
})}
>
{canResumeGame ? '▶️' : '🚀'}
🚀
</span>
<span>{canResumeGame ? 'RESUME GAME' : 'START GAME'}</span>
<span>START GAME</span>
<span
className={css({
fontSize: { base: '18px', sm: '20px', md: '24px' },
@@ -697,7 +528,7 @@ export function SetupPhase() {
animationDelay: '0.5s',
})}
>
{canResumeGame ? '🎮' : '🎮'}
🎮
</span>
</div>
</button>

View File

@@ -1,560 +0,0 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo, useReducer } from 'react'
import { useRouter } from 'next/navigation'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useViewerId } from '@/hooks/useViewerId'
import { useUserPlayers } from '@/hooks/useUserPlayers'
import { generateGameCards } from '../utils/cardGeneration'
import { validateMatch } from '../utils/matchValidation'
import { MemoryPairsContext } from './MemoryPairsContext'
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
// Initial state for local-only games
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
flippedCards: [],
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: '',
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
playerHovers: {},
}
// Action types for local reducer
type LocalAction =
| { type: 'START_GAME'; cards: any[]; activePlayers: string[]; playerMetadata: any }
| { type: 'FLIP_CARD'; cardId: string }
| { type: 'MATCH_FOUND'; cardIds: [string, string]; playerId: string }
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
| { type: 'CLEAR_MISMATCH' }
| { type: 'SWITCH_PLAYER' }
| { type: 'GO_TO_SETUP' }
| { type: 'SET_CONFIG'; field: string; value: any }
| { type: 'RESUME_GAME' }
| { type: 'HOVER_CARD'; playerId: string; cardId: string | null }
| { type: 'END_GAME' }
// Pure client-side reducer with complete game logic
function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction): MemoryPairsState {
switch (action.type) {
case 'START_GAME':
return {
...state,
gamePhase: 'playing',
gameCards: action.cards,
cards: action.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
activePlayers: action.activePlayers,
playerMetadata: action.playerMetadata,
currentPlayer: action.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
}
case 'FLIP_CARD': {
const card = state.gameCards.find((c) => c.id === action.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime: state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2,
showMismatchFeedback: false,
}
}
case 'MATCH_FOUND': {
const [id1, id2] = action.cardIds
const updatedCards = state.gameCards.map((card) =>
card.id === id1 || card.id === id2
? { ...card, matched: true, matchedBy: action.playerId }
: card
)
const newMatchedPairs = state.matchedPairs + 1
const newScores = {
...state.scores,
[action.playerId]: (state.scores[action.playerId] || 0) + 1,
}
const newConsecutiveMatches = {
...state.consecutiveMatches,
[action.playerId]: (state.consecutiveMatches[action.playerId] || 0) + 1,
}
// Check if game is complete
const gameComplete = newMatchedPairs >= state.totalPairs
return {
...state,
gameCards: updatedCards,
cards: updatedCards,
flippedCards: [],
matchedPairs: newMatchedPairs,
moves: state.moves + 1,
scores: newScores,
consecutiveMatches: newConsecutiveMatches,
lastMatchedPair: action.cardIds,
isProcessingMove: false,
showMismatchFeedback: false,
gamePhase: gameComplete ? 'results' : state.gamePhase,
gameEndTime: gameComplete ? Date.now() : null,
// Player keeps their turn on match
}
}
case 'MATCH_FAILED': {
// Reset consecutive matches for current player
const newConsecutiveMatches = {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
}
return {
...state,
moves: state.moves + 1,
showMismatchFeedback: true,
isProcessingMove: true,
consecutiveMatches: newConsecutiveMatches,
// Don't clear flipped cards yet - CLEAR_MISMATCH will do that
}
}
case 'CLEAR_MISMATCH': {
return {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
}
}
case 'SWITCH_PLAYER': {
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
const nextPlayer = state.activePlayers[nextIndex]
return {
...state,
currentPlayer: nextPlayer,
currentMoveStartTime: Date.now(),
}
}
case 'GO_TO_SETUP': {
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
...state,
gamePhase: 'setup',
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
gameCards: state.gameCards,
currentPlayer: state.currentPlayer,
matchedPairs: state.matchedPairs,
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata || {},
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
gameCards: [],
cards: [],
flippedCards: [],
currentPlayer: '',
matchedPairs: 0,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
}
case 'SET_CONFIG': {
const clearPausedGame = !!state.pausedGamePhase
return {
...state,
[action.field]: action.value,
...(action.field === 'difficulty' ? { totalPairs: action.value } : {}),
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
}
}
case 'RESUME_GAME': {
if (!state.pausedGamePhase || !state.pausedGameState) {
return state
}
return {
...state,
gamePhase: state.pausedGamePhase,
gameCards: state.pausedGameState.gameCards,
cards: state.pausedGameState.gameCards,
currentPlayer: state.pausedGameState.currentPlayer,
matchedPairs: state.pausedGameState.matchedPairs,
moves: state.pausedGameState.moves,
scores: state.pausedGameState.scores,
activePlayers: state.pausedGameState.activePlayers,
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
case 'HOVER_CARD': {
return {
...state,
playerHovers: {
...state.playerHovers,
[action.playerId]: action.cardId,
},
}
}
case 'END_GAME': {
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
default:
return state
}
}
// Provider component for LOCAL-ONLY play (no network, no arcade session)
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
const router = useRouter()
const { data: viewerId } = useViewerId()
// LOCAL-ONLY: Get only the current user's players (no room members)
const { data: userPlayers = [] } = useUserPlayers()
// Use arcade redirect to determine button visibility
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
// Build players map from current user's players only
const players = useMemo(() => {
const map = new Map()
userPlayers.forEach((player) => {
map.set(player.id, {
id: player.id,
name: player.name,
emoji: player.emoji,
color: player.color,
isLocal: true,
})
})
return map
}, [userPlayers])
// Get active player IDs from current user's players only
const activePlayers = useMemo(() => {
return userPlayers.filter((p) => p.isActive).map((p) => p.id)
}, [userPlayers])
// Derive game mode from active player count
const gameMode = activePlayers.length > 1 ? 'multiplayer' : 'single'
// Pure client-side state with useReducer
const [state, dispatch] = useReducer(localMemoryPairsReducer, initialState)
// Handle mismatch feedback timeout and player switching
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
const timeout = setTimeout(() => {
dispatch({ type: 'CLEAR_MISMATCH' })
// Switch to next player after mismatch
dispatch({ type: 'SWITCH_PLAYER' })
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length])
// Handle automatic match checking when 2 cards flipped
useEffect(() => {
if (state.flippedCards.length === 2 && !state.showMismatchFeedback) {
const [card1, card2] = state.flippedCards
const isMatch = validateMatch(card1, card2)
const timeout = setTimeout(() => {
if (isMatch.isValid) {
dispatch({
type: 'MATCH_FOUND',
cardIds: [card1.id, card2.id],
playerId: state.currentPlayer,
})
// Player keeps turn on match - no SWITCH_PLAYER
} else {
dispatch({
type: 'MATCH_FAILED',
cardIds: [card1.id, card2.id],
})
// SWITCH_PLAYER will happen after CLEAR_MISMATCH timeout
}
}, 600) // Small delay to show both cards
return () => clearTimeout(timeout)
}
}, [state.flippedCards, state.showMismatchFeedback, state.currentPlayer])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
if (!isGameActive || state.isProcessingMove) {
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
return false
}
if (state.flippedCards.some((c) => c.id === cardId)) {
return false
}
if (state.flippedCards.length >= 2) {
return false
}
// In local play, all local players can flip during their turn
const currentPlayerData = players.get(state.currentPlayer)
if (currentPlayerData && currentPlayerData.isLocal === false) {
return false
}
return true
},
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards, state.currentPlayer, players]
)
const currentGameStatistics: GameStatistics = useMemo(
() => ({
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove:
state.moves > 0 && state.gameStartTime
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}),
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
)
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer
)
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Action creators
const startGame = useCallback(() => {
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot start game without active players')
return
}
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId || '',
color: playerData.color,
}
}
}
const cards = generateGameCards(state.gameType, state.difficulty)
dispatch({
type: 'START_GAME',
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const flipCard = useCallback(
(cardId: string) => {
if (!canFlipCard(cardId)) {
return
}
dispatch({ type: 'FLIP_CARD', cardId })
},
[canFlipCard]
)
const resetGame = useCallback(() => {
if (activePlayers.length === 0) {
console.error('[LocalMemoryPairs] Cannot reset game without active players')
return
}
const playerMetadata: { [playerId: string]: any } = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId || '',
color: playerData.color,
}
}
}
const cards = generateGameCards(state.gameType, state.difficulty)
dispatch({
type: 'START_GAME',
cards,
activePlayers,
playerMetadata,
})
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
const setGameType = useCallback((gameType: typeof state.gameType) => {
dispatch({ type: 'SET_CONFIG', field: 'gameType', value: gameType })
}, [])
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
dispatch({ type: 'SET_CONFIG', field: 'difficulty', value: difficulty })
}, [])
const setTurnTimer = useCallback((turnTimer: typeof state.turnTimer) => {
dispatch({ type: 'SET_CONFIG', field: 'turnTimer', value: turnTimer })
}, [])
const resumeGame = useCallback(() => {
if (!canResumeGame) {
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
return
}
dispatch({ type: 'RESUME_GAME' })
}, [canResumeGame])
const goToSetup = useCallback(() => {
dispatch({ type: 'GO_TO_SETUP' })
}, [])
const hoverCard = useCallback(
(cardId: string | null) => {
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return
dispatch({
type: 'HOVER_CARD',
playerId,
cardId,
})
},
[state.currentPlayer, activePlayers]
)
const exitSession = useCallback(() => {
router.push('/arcade')
}, [router])
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
const contextValue: MemoryPairsContextValue = {
state: effectiveState,
dispatch: () => {
// No-op - local provider uses action creators instead
console.warn('dispatch() is not available in local mode, use action creators instead')
},
isGameActive,
canFlipCard,
currentGameStatistics,
hasConfigChanged,
canResumeGame,
canModifyPlayers,
startGame,
resumeGame,
flipCard,
resetGame,
goToSetup,
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,
}
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}

View File

@@ -251,7 +251,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
}
// Create context
export const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
// Provider component
export function MemoryPairsProvider({ children }: { children: ReactNode }) {

View File

@@ -1,595 +0,0 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { generateGameCards } from '../utils/cardGeneration'
import { MemoryPairsContext } from './MemoryPairsContext'
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
// Initial state
const initialState: MemoryPairsState = {
cards: [],
gameCards: [],
flippedCards: [],
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
gamePhase: 'setup',
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Player metadata for cross-user visibility
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Initialize paused game fields
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
/**
* Optimistic move application (client-side prediction)
* The server will validate and send back the authoritative state
*/
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
switch (move.type) {
case 'START_GAME':
// Generate cards and initialize game
return {
...state,
gamePhase: 'playing',
gameCards: move.data.cards,
cards: move.data.cards,
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: move.data.activePlayers.reduce(
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
{}
),
activePlayers: move.data.activePlayers,
playerMetadata: move.data.playerMetadata || {}, // Include player metadata
currentPlayer: move.data.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Save original config and clear paused state
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
}
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find((c) => c.id === move.data.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
showMismatchFeedback: false,
}
}
case 'CLEAR_MISMATCH': {
// Clear mismatched cards and feedback
return {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
}
}
case 'GO_TO_SETUP': {
// Return to setup phase - pause game if coming from playing/results
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
...state,
gamePhase: 'setup',
// PAUSE: Save game state if pausing from active game
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
gameCards: state.gameCards,
currentPlayer: state.currentPlayer,
matchedPairs: state.matchedPairs,
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
// Reset visible game state
gameCards: [],
cards: [],
flippedCards: [],
currentPlayer: '',
matchedPairs: 0,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
}
}
case 'SET_CONFIG': {
// Update configuration field optimistically
const { field, value } = move.data as { field: string; value: any }
const clearPausedGame = !!state.pausedGamePhase
return {
...state,
[field]: value,
// Update totalPairs if difficulty changes
...(field === 'difficulty' ? { totalPairs: value } : {}),
// Clear paused game if config changed
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
}
}
case 'RESUME_GAME': {
// Resume paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return state // No paused game, no-op
}
return {
...state,
gamePhase: state.pausedGamePhase,
gameCards: state.pausedGameState.gameCards,
cards: state.pausedGameState.gameCards,
currentPlayer: state.pausedGameState.currentPlayer,
matchedPairs: state.pausedGameState.matchedPairs,
moves: state.pausedGameState.moves,
scores: state.pausedGameState.scores,
activePlayers: state.pausedGameState.activePlayers,
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
// Clear paused state
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
case 'HOVER_CARD': {
// Update player hover state for networked presence
return {
...state,
playerHovers: {
...state.playerHovers,
[move.playerId]: move.data.cardId,
},
}
}
default:
return state
}
}
// Provider component for ROOM-BASED play (with network sync)
// NOTE: This provider should ONLY be used for room-based multiplayer games.
// For arcade sessions without rooms, use LocalMemoryPairsProvider instead.
export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData() // Fetch room data for room-based play
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
// Get active player IDs directly as strings (UUIDs)
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
// NO LOCAL STATE - Configuration lives in session state
// Changes are sent as moves and synchronized across all room members
// Arcade session integration WITH room sync
const {
state,
sendMove,
connected: _connected,
exitSession,
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState,
applyMove: applyMoveOptimistically,
})
// Handle mismatch feedback timeout
useEffect(() => {
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
// After 1.5 seconds, send CLEAR_MISMATCH
// Server will validate that cards are still in mismatch state before clearing
const timeout = setTimeout(() => {
sendMove({
type: 'CLEAR_MISMATCH',
playerId: state.currentPlayer,
data: {},
})
}, 1500)
return () => clearTimeout(timeout)
}
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
// Computed values
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
console.log('[RoomProvider][canFlipCard] Checking card:', {
cardId,
isGameActive,
isProcessingMove: state.isProcessingMove,
currentPlayer: state.currentPlayer,
hasRoomData: !!roomData,
flippedCardsCount: state.flippedCards.length,
})
if (!isGameActive || state.isProcessingMove) {
console.log('[RoomProvider][canFlipCard] Blocked: game not active or processing')
return false
}
const card = state.gameCards.find((c) => c.id === cardId)
if (!card || card.matched) {
console.log('[RoomProvider][canFlipCard] Blocked: card not found or already matched')
return false
}
// Can't flip if already flipped
if (state.flippedCards.some((c) => c.id === cardId)) {
console.log('[RoomProvider][canFlipCard] Blocked: card already flipped')
return false
}
// Can't flip more than 2 cards
if (state.flippedCards.length >= 2) {
console.log('[RoomProvider][canFlipCard] Blocked: 2 cards already flipped')
return false
}
// Authorization check: Only allow flipping if it's your player's turn
if (roomData && state.currentPlayer) {
const currentPlayerData = players.get(state.currentPlayer)
console.log('[RoomProvider][canFlipCard] Authorization check:', {
currentPlayerId: state.currentPlayer,
currentPlayerFound: !!currentPlayerData,
currentPlayerIsLocal: currentPlayerData?.isLocal,
})
// Block if current player is explicitly marked as remote (isLocal === false)
if (currentPlayerData && currentPlayerData.isLocal === false) {
console.log(
'[RoomProvider][canFlipCard] BLOCKED: Current player is remote (not your turn)'
)
return false
}
// If player data not found in map, this might be an issue - allow for now but warn
if (!currentPlayerData) {
console.warn(
'[RoomProvider][canFlipCard] WARNING: Current player not found in players map, allowing move'
)
}
}
console.log('[RoomProvider][canFlipCard] ALLOWED: All checks passed')
return true
},
[
isGameActive,
state.isProcessingMove,
state.gameCards,
state.flippedCards,
state.currentPlayer,
roomData,
players,
]
)
const currentGameStatistics: GameStatistics = useMemo(
() => ({
totalMoves: state.moves,
matchedPairs: state.matchedPairs,
totalPairs: state.totalPairs,
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
averageTimePerMove:
state.moves > 0 && state.gameStartTime
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}),
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
)
// PAUSE/RESUME: Computed values for pause/resume functionality
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer
)
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Helper to build player metadata with correct userId ownership
// This uses roomData.memberPlayers to determine which user owns which player
const buildPlayerMetadata = useCallback(
(playerIds: string[]) => {
const playerMetadata: { [playerId: string]: any } = {}
// Build reverse mapping: playerId -> userId from roomData.memberPlayers
const playerOwnership = new Map<string, string>()
if (roomData?.memberPlayers) {
for (const [userId, userPlayers] of Object.entries(roomData.memberPlayers)) {
for (const player of userPlayers) {
playerOwnership.set(player.id, userId)
}
}
}
for (const playerId of playerIds) {
const playerData = players.get(playerId)
if (playerData) {
// Get the actual owner userId from roomData, or use local viewerId as fallback
const ownerUserId = playerOwnership.get(playerId) || viewerId || ''
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: ownerUserId, // CORRECT: Use actual owner's userId
color: playerData.color,
}
}
}
return playerMetadata
},
[players, roomData, viewerId]
)
// Action creators - send moves to arcade session
const startGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[RoomMemoryPairs] Cannot start game without active players')
return
}
// Capture player metadata from local players map
// This ensures all room members can display player info even if they don't own the players
const playerMetadata = buildPlayerMetadata(activePlayers)
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
const flipCard = useCallback(
(cardId: string) => {
console.log('[RoomProvider] flipCard called:', {
cardId,
viewerId,
currentPlayer: state.currentPlayer,
activePlayers: state.activePlayers,
gamePhase: state.gamePhase,
canFlip: canFlipCard(cardId),
})
if (!canFlipCard(cardId)) {
console.log('[RoomProvider] Cannot flip card - canFlipCard returned false')
return
}
const move = {
type: 'FLIP_CARD' as const,
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
data: { cardId },
}
console.log('[RoomProvider] Sending FLIP_CARD move via sendMove:', move)
sendMove(move)
},
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
)
const resetGame = useCallback(() => {
// Must have at least one active player
if (activePlayers.length === 0) {
console.error('[RoomMemoryPairs] Cannot reset game without active players')
return
}
// Capture player metadata with correct userId ownership
const playerMetadata = buildPlayerMetadata(activePlayers)
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0]
sendMove({
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
const setGameType = useCallback(
(gameType: typeof state.gameType) => {
// Use first active player as playerId, or empty string if none
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'gameType', value: gameType },
})
},
[activePlayers, sendMove]
)
const setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'difficulty', value: difficulty },
})
},
[activePlayers, sendMove]
)
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
const playerId = activePlayers[0] || ''
sendMove({
type: 'SET_CONFIG',
playerId,
data: { field: 'turnTimer', value: turnTimer },
})
},
[activePlayers, sendMove]
)
const goToSetup = useCallback(() => {
// Send GO_TO_SETUP move - synchronized across all room members
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'GO_TO_SETUP',
playerId,
data: {},
})
}, [activePlayers, state.currentPlayer, sendMove])
const resumeGame = useCallback(() => {
// PAUSE/RESUME: Resume paused game if config unchanged
if (!canResumeGame) {
console.warn('[RoomMemoryPairs] Cannot resume - no paused game or config changed')
return
}
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: 'RESUME_GAME',
playerId,
data: {},
})
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
const hoverCard = useCallback(
(cardId: string | null) => {
// HOVER: Send hover state for networked presence
// Use current player as the one hovering
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return // No active player to send hover for
sendMove({
type: 'HOVER_CARD',
playerId,
data: { cardId },
})
},
[state.currentPlayer, activePlayers, sendMove]
)
// NO MORE effectiveState merging! Just use session state directly with gameMode added
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
const contextValue: MemoryPairsContextValue = {
state: effectiveState,
dispatch: () => {
// No-op - replaced with sendMove
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
},
isGameActive,
canFlipCard,
currentGameStatistics,
hasConfigChanged,
canResumeGame,
canModifyPlayers: false, // Room-based games: always show buttons (false = show buttons)
startGame,
resumeGame,
flipCard,
resetGame,
goToSetup,
setGameType,
setDifficulty,
setTurnTimer,
hoverCard,
exitSession,
gameMode,
activePlayers,
}
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}
// Export the hook for this provider
export { useMemoryPairs } from './MemoryPairsContext'

View File

@@ -1,151 +0,0 @@
/**
* Unit test for player ownership bug in RoomMemoryPairsProvider
*
* Bug: playerMetadata[playerId].userId is set to the LOCAL viewerId for ALL players,
* including remote players from other room members. This causes "Your turn" to show
* even when it's a remote player's turn.
*
* Fix: Use player.isLocal from GameModeContext to determine correct userId ownership.
*/
import { describe, expect, it } from 'vitest'
describe('Player Metadata userId Assignment', () => {
it('should assign local userId to local players only', () => {
const viewerId = 'local-user-id'
const players = new Map([
[
'local-player-1',
{
id: 'local-player-1',
name: 'Local Player',
emoji: '😀',
color: '#3b82f6',
isLocal: true,
},
],
[
'remote-player-1',
{
id: 'remote-player-1',
name: 'Remote Player',
emoji: '🤠',
color: '#10b981',
isLocal: false,
},
],
])
const activePlayers = ['local-player-1', 'remote-player-1']
// CURRENT BUGGY IMPLEMENTATION (from RoomMemoryPairsProvider.tsx:378-390)
const buggyPlayerMetadata: Record<string, any> = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
buggyPlayerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: viewerId, // BUG: Always uses local viewerId!
color: playerData.color,
}
}
}
// BUG MANIFESTATION: Both players have local userId
expect(buggyPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
expect(buggyPlayerMetadata['remote-player-1'].userId).toBe('local-user-id') // WRONG!
// CORRECT IMPLEMENTATION
const correctPlayerMetadata: Record<string, any> = {}
for (const playerId of activePlayers) {
const playerData = players.get(playerId)
if (playerData) {
correctPlayerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
// FIX: Only use local viewerId for local players
// For remote players, we don't know their userId from this context,
// but we can mark them as NOT belonging to local user
userId: playerData.isLocal ? viewerId : `remote-user-${playerId}`,
color: playerData.color,
isLocal: playerData.isLocal, // Also include isLocal for clarity
}
}
}
// CORRECT BEHAVIOR: Each player has correct userId
expect(correctPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
expect(correctPlayerMetadata['remote-player-1'].userId).not.toBe('local-user-id')
})
it('reproduces "Your turn" bug when checking current player', () => {
const viewerId = 'local-user-id'
const currentPlayer = 'remote-player-1' // Remote player's turn
// Buggy playerMetadata (all players have local userId)
const buggyPlayerMetadata = {
'local-player-1': {
id: 'local-player-1',
userId: 'local-user-id',
},
'remote-player-1': {
id: 'remote-player-1',
userId: 'local-user-id', // BUG!
},
}
// PlayerStatusBar logic (line 31 in PlayerStatusBar.tsx)
const buggyIsLocalPlayer = buggyPlayerMetadata[currentPlayer]?.userId === viewerId
// BUG: Shows "Your turn" even though it's remote player's turn!
expect(buggyIsLocalPlayer).toBe(true) // WRONG!
expect(buggyIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Your turn') // WRONG!
// Correct playerMetadata (each player has correct userId)
const correctPlayerMetadata = {
'local-player-1': {
id: 'local-player-1',
userId: 'local-user-id',
},
'remote-player-1': {
id: 'remote-player-1',
userId: 'remote-user-id', // CORRECT!
},
}
// PlayerStatusBar logic with correct data
const correctIsLocalPlayer = correctPlayerMetadata[currentPlayer]?.userId === viewerId
// CORRECT: Shows "Their turn" because it's remote player's turn
expect(correctIsLocalPlayer).toBe(false) // CORRECT!
expect(correctIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Their turn') // CORRECT!
})
it('reproduces hover avatar bug when filtering by current player', () => {
const viewerId = 'local-user-id'
const currentPlayer = 'remote-player-1' // Remote player's turn
// Buggy playerMetadata
const buggyPlayerMetadata = {
'remote-player-1': {
id: 'remote-player-1',
userId: 'local-user-id', // BUG!
},
}
// OLD WRONG logic from MemoryGrid.tsx (showed remote players)
const oldWrongFilter = buggyPlayerMetadata[currentPlayer]?.userId !== viewerId
expect(oldWrongFilter).toBe(false) // Would hide avatar incorrectly
// CURRENT logic in MemoryGrid.tsx (shows only current player)
// This is actually correct - show avatar for whoever's turn it is
const currentLogic = currentPlayer === 'remote-player-1'
expect(currentLogic).toBe(true) // Shows avatar for current player
// The REAL issue is in PlayerStatusBar showing "Your turn"
// when it should show "Their turn"
})
})

View File

@@ -1,20 +0,0 @@
/**
* Central export point for arcade matching game context
* Re-exports the hook from the appropriate provider
*/
// Export the hook (works with both local and room providers)
export { useMemoryPairs } from './MemoryPairsContext'
// Export the room provider (networked multiplayer)
export { RoomMemoryPairsProvider } from './RoomMemoryPairsProvider'
// Export types
export type {
GameCard,
GameMode,
GamePhase,
GameType,
MemoryPairsState,
MemoryPairsContextValue,
} from './types'

View File

@@ -59,7 +59,6 @@ export interface MemoryPairsState {
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// Timing
@@ -73,24 +72,6 @@ export interface MemoryPairsState {
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// PAUSE/RESUME: Paused game state
originalConfig?: { gameType: GameType; difficulty: Difficulty; turnTimer: number }
pausedGamePhase?: GamePhase
pausedGameState?: {
gameCards: GameCard[]
currentPlayer: Player
matchedPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[]
playerMetadata: { [playerId: string]: any }
consecutiveMatches: { [playerId: string]: number }
gameStartTime: number | null
}
// HOVER: Networked hover state
playerHovers?: { [playerId: string]: string | null }
}
export type MemoryPairsAction =
@@ -120,11 +101,6 @@ export interface MemoryPairsContextValue {
currentGameStatistics: GameStatistics
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
canModifyPlayers: boolean // Whether players can be added/removed (controls button visibility)
// PAUSE/RESUME: Computed pause/resume values
hasConfigChanged?: boolean
canResumeGame?: boolean
// Actions
startGame: () => void
@@ -132,10 +108,6 @@ export interface MemoryPairsContextValue {
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer?: (timer: number) => void
goToSetup?: () => void
resumeGame?: () => void
hoverCard?: (cardId: string | null) => void
exitSession: () => void
}

View File

@@ -1,13 +1,13 @@
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
import { MemoryPairsGame } from './components/MemoryPairsGame'
import { LocalMemoryPairsProvider } from './context/LocalMemoryPairsProvider'
import { ArcadeMemoryPairsProvider } from './context/ArcadeMemoryPairsContext'
export default function MatchingPage() {
return (
<ArcadeGuardedPage>
<LocalMemoryPairsProvider>
<ArcadeMemoryPairsProvider>
<MemoryPairsGame />
</LocalMemoryPairsProvider>
</ArcadeMemoryPairsProvider>
</ArcadeGuardedPage>
)
}

View File

@@ -2,7 +2,7 @@
import { useRoomData } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
/**
* /arcade/room - Renders the game for the user's current room
@@ -68,9 +68,9 @@ export default function RoomPage() {
switch (roomData.gameName) {
case 'matching':
return (
<RoomMemoryPairsProvider>
<ArcadeMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
</ArcadeMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)

View File

@@ -1,3 +0,0 @@
"use strict";
// TypeScript interfaces for Memory Pairs Challenge game
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -40,20 +40,6 @@ export interface GameStatistics {
averageTimePerMove: number
}
export interface PlayerMetadata {
id: string // Player ID
name: string
emoji: string
userId: string // Which user owns this player
color?: string
}
export interface GameConfiguration {
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
export interface MemoryPairsState {
// Core game data
cards: GameCard[]
@@ -65,22 +51,6 @@ export interface MemoryPairsState {
difficulty: Difficulty
turnTimer: number // Seconds for two-player mode
// Paused game state - for Resume functionality
originalConfig?: GameConfiguration // Config when game started - used to detect changes
pausedGamePhase?: 'playing' | 'results' // Set when GO_TO_SETUP called from active game
pausedGameState?: {
// Snapshot of game state when paused
gameCards: GameCard[]
currentPlayer: Player
matchedPairs: number
moves: number
scores: PlayerScore
activePlayers: Player[]
playerMetadata: { [playerId: string]: PlayerMetadata }
consecutiveMatches: { [playerId: string]: number }
gameStartTime: number | null
}
// Game progression
gamePhase: GamePhase
currentPlayer: Player
@@ -89,7 +59,6 @@ export interface MemoryPairsState {
moves: number
scores: PlayerScore
activePlayers: Player[] // Track active player IDs
playerMetadata: { [playerId: string]: PlayerMetadata } // Player metadata snapshot for cross-user visibility
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
// Timing
@@ -103,9 +72,6 @@ export interface MemoryPairsState {
isProcessingMove: boolean
showMismatchFeedback: boolean
lastMatchedPair: [string, string] | null
// Hover state for networked presence
playerHovers: { [playerId: string]: string | null } // playerId -> cardId (or null if not hovering)
}
export type MemoryPairsAction =
@@ -135,19 +101,13 @@ export interface MemoryPairsContextValue {
currentGameStatistics: GameStatistics
gameMode: GameMode // Derived from global context
activePlayers: Player[] // Active player IDs from arena
hasConfigChanged: boolean // True if current config differs from originalConfig
canResumeGame: boolean // True if there's a paused game and config hasn't changed
// Actions
startGame: () => void
resumeGame: () => void
flipCard: (cardId: string) => void
resetGame: () => void
setGameType: (type: GameType) => void
setDifficulty: (difficulty: Difficulty) => void
setTurnTimer: (timer: number) => void
hoverCard: (cardId: string | null) => void // Send hover state for networked presence
goToSetup: () => void
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
}
@@ -173,6 +133,14 @@ export interface GameGridProps {
disabled?: boolean
}
// Configuration interfaces
export interface GameConfiguration {
gameMode: GameMode
gameType: GameType
difficulty: Difficulty
turnTimer: number
}
export interface MatchValidationResult {
isValid: boolean
reason?: string

View File

@@ -1,164 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateAbacusNumeralCards = generateAbacusNumeralCards;
exports.generateComplementCards = generateComplementCards;
exports.generateGameCards = generateGameCards;
exports.getGridConfiguration = getGridConfiguration;
exports.generateCardId = generateCardId;
// Utility function to generate unique random numbers
function generateUniqueNumbers(count, options) {
const numbers = new Set();
const { min, max } = options;
while (numbers.size < count) {
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min;
numbers.add(randomNum);
}
return Array.from(numbers);
}
// Utility function to shuffle an array
function shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
// Generate cards for abacus-numeral game mode
function generateAbacusNumeralCards(pairs) {
// Generate unique numbers based on difficulty
// For easier games, use smaller numbers; for harder games, use larger ranges
const numberRanges = {
6: { min: 1, max: 50 }, // 6 pairs: 1-50
8: { min: 1, max: 100 }, // 8 pairs: 1-100
12: { min: 1, max: 200 }, // 12 pairs: 1-200
15: { min: 1, max: 300 }, // 15 pairs: 1-300
};
const range = numberRanges[pairs];
const numbers = generateUniqueNumbers(pairs, range);
const cards = [];
numbers.forEach((number) => {
// Abacus representation card
cards.push({
id: `abacus_${number}`,
type: 'abacus',
number,
matched: false,
});
// Numerical representation card
cards.push({
id: `number_${number}`,
type: 'number',
number,
matched: false,
});
});
return shuffleArray(cards);
}
// Generate cards for complement pairs game mode
function generateComplementCards(pairs) {
// Define complement pairs for friends of 5 and friends of 10
const complementPairs = [
// Friends of 5
{ pair: [0, 5], targetSum: 5 },
{ pair: [1, 4], targetSum: 5 },
{ pair: [2, 3], targetSum: 5 },
// Friends of 10
{ pair: [0, 10], targetSum: 10 },
{ pair: [1, 9], targetSum: 10 },
{ pair: [2, 8], targetSum: 10 },
{ pair: [3, 7], targetSum: 10 },
{ pair: [4, 6], targetSum: 10 },
{ pair: [5, 5], targetSum: 10 },
// Additional pairs for higher difficulties
{ pair: [6, 4], targetSum: 10 },
{ pair: [7, 3], targetSum: 10 },
{ pair: [8, 2], targetSum: 10 },
{ pair: [9, 1], targetSum: 10 },
{ pair: [10, 0], targetSum: 10 },
// More challenging pairs (can be used for expert mode)
{ pair: [11, 9], targetSum: 20 },
{ pair: [12, 8], targetSum: 20 },
];
// Select the required number of complement pairs
const selectedPairs = complementPairs.slice(0, pairs);
const cards = [];
selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => {
// First number in the pair
cards.push({
id: `comp1_${index}_${num1}`,
type: 'complement',
number: num1,
complement: num2,
targetSum,
matched: false,
});
// Second number in the pair
cards.push({
id: `comp2_${index}_${num2}`,
type: 'complement',
number: num2,
complement: num1,
targetSum,
matched: false,
});
});
return shuffleArray(cards);
}
// Main card generation function
function generateGameCards(gameType, difficulty) {
switch (gameType) {
case 'abacus-numeral':
return generateAbacusNumeralCards(difficulty);
case 'complement-pairs':
return generateComplementCards(difficulty);
default:
throw new Error(`Unknown game type: ${gameType}`);
}
}
// Utility function to get responsive grid configuration based on difficulty and screen size
function getGridConfiguration(difficulty) {
const configs = {
6: {
totalCards: 12,
mobileColumns: 3, // 3x4 grid in portrait
tabletColumns: 4, // 4x3 grid on tablet
desktopColumns: 4, // 4x3 grid on desktop
landscapeColumns: 6, // 6x2 grid in landscape
cardSize: { width: '140px', height: '180px' },
gridTemplate: 'repeat(3, 1fr)',
},
8: {
totalCards: 16,
mobileColumns: 3, // 3x6 grid in portrait (some spillover)
tabletColumns: 4, // 4x4 grid on tablet
desktopColumns: 4, // 4x4 grid on desktop
landscapeColumns: 6, // 6x3 grid in landscape (some spillover)
cardSize: { width: '120px', height: '160px' },
gridTemplate: 'repeat(3, 1fr)',
},
12: {
totalCards: 24,
mobileColumns: 3, // 3x8 grid in portrait
tabletColumns: 4, // 4x6 grid on tablet
desktopColumns: 6, // 6x4 grid on desktop
landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3)
cardSize: { width: '100px', height: '140px' },
gridTemplate: 'repeat(3, 1fr)',
},
15: {
totalCards: 30,
mobileColumns: 3, // 3x10 grid in portrait
tabletColumns: 5, // 5x6 grid on tablet
desktopColumns: 6, // 6x5 grid on desktop
landscapeColumns: 10, // 10x3 grid in landscape
cardSize: { width: '90px', height: '120px' },
gridTemplate: 'repeat(3, 1fr)',
},
};
return configs[difficulty];
}
// Generate a unique ID for cards
function generateCardId(type, identifier) {
return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}

View File

@@ -1,188 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.validateAbacusNumeralMatch = validateAbacusNumeralMatch;
exports.validateComplementMatch = validateComplementMatch;
exports.validateMatch = validateMatch;
exports.canFlipCard = canFlipCard;
exports.getMatchHint = getMatchHint;
exports.calculateMatchScore = calculateMatchScore;
exports.analyzeGamePerformance = analyzeGamePerformance;
// Validate abacus-numeral match (abacus card matches with number card of same value)
function validateAbacusNumeralMatch(card1, card2) {
// Both cards must have the same number
if (card1.number !== card2.number) {
return {
isValid: false,
reason: 'Numbers do not match',
type: 'invalid',
};
}
// Cards must be different types (one abacus, one number)
if (card1.type === card2.type) {
return {
isValid: false,
reason: 'Both cards are the same type',
type: 'invalid',
};
}
// One must be abacus, one must be number
const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus';
const hasNumber = card1.type === 'number' || card2.type === 'number';
if (!hasAbacus || !hasNumber) {
return {
isValid: false,
reason: 'Must match abacus with number representation',
type: 'invalid',
};
}
// Neither should be complement type for this game mode
if (card1.type === 'complement' || card2.type === 'complement') {
return {
isValid: false,
reason: 'Complement cards not valid in abacus-numeral mode',
type: 'invalid',
};
}
return {
isValid: true,
type: 'abacus-numeral',
};
}
// Validate complement match (two numbers that add up to target sum)
function validateComplementMatch(card1, card2) {
// Both cards must be complement type
if (card1.type !== 'complement' || card2.type !== 'complement') {
return {
isValid: false,
reason: 'Both cards must be complement type',
type: 'invalid',
};
}
// Both cards must have the same target sum
if (card1.targetSum !== card2.targetSum) {
return {
isValid: false,
reason: 'Cards have different target sums',
type: 'invalid',
};
}
// Check if the numbers are actually complements
if (!card1.complement || !card2.complement) {
return {
isValid: false,
reason: 'Complement information missing',
type: 'invalid',
};
}
// Verify the complement relationship
if (card1.number !== card2.complement || card2.number !== card1.complement) {
return {
isValid: false,
reason: 'Numbers are not complements of each other',
type: 'invalid',
};
}
// Verify the sum equals the target
const sum = card1.number + card2.number;
if (sum !== card1.targetSum) {
return {
isValid: false,
reason: `Sum ${sum} does not equal target ${card1.targetSum}`,
type: 'invalid',
};
}
return {
isValid: true,
type: 'complement',
};
}
// Main validation function that determines which validation to use
function validateMatch(card1, card2) {
// Cannot match the same card with itself
if (card1.id === card2.id) {
return {
isValid: false,
reason: 'Cannot match card with itself',
type: 'invalid',
};
}
// Cannot match already matched cards
if (card1.matched || card2.matched) {
return {
isValid: false,
reason: 'Cannot match already matched cards',
type: 'invalid',
};
}
// Determine which type of match to validate based on card types
const hasComplement = card1.type === 'complement' || card2.type === 'complement';
if (hasComplement) {
// If either card is complement type, use complement validation
return validateComplementMatch(card1, card2);
}
else {
// Otherwise, use abacus-numeral validation
return validateAbacusNumeralMatch(card1, card2);
}
}
// Helper function to check if a card can be flipped
function canFlipCard(card, flippedCards, isProcessingMove) {
// Cannot flip if processing a move
if (isProcessingMove)
return false;
// Cannot flip already matched cards
if (card.matched)
return false;
// Cannot flip if already flipped
if (flippedCards.some((c) => c.id === card.id))
return false;
// Cannot flip if two cards are already flipped
if (flippedCards.length >= 2)
return false;
return true;
}
// Get hint for what kind of match the player should look for
function getMatchHint(card) {
switch (card.type) {
case 'abacus':
return `Find the number ${card.number}`;
case 'number':
return `Find the abacus showing ${card.number}`;
case 'complement':
if (card.complement !== undefined && card.targetSum !== undefined) {
return `Find ${card.complement} to make ${card.targetSum}`;
}
return 'Find the matching complement';
default:
return 'Find the matching card';
}
}
// Calculate match score based on difficulty and time
function calculateMatchScore(difficulty, timeForMatch, isComplementMatch) {
const baseScore = isComplementMatch ? 15 : 10; // Complement matches worth more
const difficultyMultiplier = difficulty / 6; // Scale with difficulty
const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000); // Bonus for speed
return Math.round(baseScore * difficultyMultiplier + timeBonus);
}
// Analyze game performance
function analyzeGamePerformance(totalMoves, matchedPairs, totalPairs, gameTime) {
const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0;
const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0; // Ideal is 100% (each pair found in 2 moves)
const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0;
// Calculate grade based on accuracy and efficiency
let grade = 'F';
if (accuracy >= 90 && efficiency >= 80)
grade = 'A';
else if (accuracy >= 80 && efficiency >= 70)
grade = 'B';
else if (accuracy >= 70 && efficiency >= 60)
grade = 'C';
else if (accuracy >= 60 && efficiency >= 50)
grade = 'D';
return {
accuracy,
efficiency,
averageTimePerMove,
grade,
};
}

View File

@@ -1,80 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.schema = exports.db = void 0;
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const better_sqlite3_2 = require("drizzle-orm/better-sqlite3");
const schema = __importStar(require("./schema"));
exports.schema = schema;
/**
* Database connection and client
*
* Creates a singleton SQLite connection with Drizzle ORM.
* Enables foreign key constraints (required for cascading deletes).
*
* IMPORTANT: The database connection is lazy-loaded to avoid accessing
* the database at module import time, which would cause build failures
* when the database doesn't exist (e.g., in CI/CD environments).
*/
const databaseUrl = process.env.DATABASE_URL || './data/sqlite.db';
let _sqlite = null;
let _db = null;
/**
* Get the database connection (lazy-loaded singleton)
* Only creates the connection when first accessed at runtime
*/
function getDb() {
if (!_db) {
_sqlite = new better_sqlite3_1.default(databaseUrl);
// Enable foreign keys (SQLite requires explicit enable)
_sqlite.pragma('foreign_keys = ON');
// Enable WAL mode for better concurrency
_sqlite.pragma('journal_mode = WAL');
_db = (0, better_sqlite3_2.drizzle)(_sqlite, { schema });
}
return _db;
}
/**
* Database client instance
* Uses a Proxy to lazy-load the connection on first access
*/
exports.db = new Proxy({}, {
get(_target, prop) {
return getDb()[prop];
},
});

View File

@@ -1,22 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const migrator_1 = require("drizzle-orm/better-sqlite3/migrator");
const index_1 = require("./index");
/**
* Migration runner
*
* Runs all pending migrations in the drizzle/ folder.
* Safe to run multiple times (migrations are idempotent).
*
* Usage: pnpm db:migrate
*/
try {
console.log('🔄 Running migrations...');
(0, migrator_1.migrate)(index_1.db, { migrationsFolder: './drizzle' });
console.log('✅ Migrations complete');
process.exit(0);
}
catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}

View File

@@ -1,53 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.abacusSettings = void 0;
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const users_1 = require("./users");
/**
* Abacus display settings table - UI preferences per user
*
* One-to-one with users table. Stores abacus display configuration.
* Deleted when user is deleted (cascade).
*/
exports.abacusSettings = (0, sqlite_core_1.sqliteTable)('abacus_settings', {
/** Primary key and foreign key to users table */
userId: (0, sqlite_core_1.text)('user_id')
.primaryKey()
.references(() => users_1.users.id, { onDelete: 'cascade' }),
/** Color scheme for beads */
colorScheme: (0, sqlite_core_1.text)('color_scheme', {
enum: ['monochrome', 'place-value', 'heaven-earth', 'alternating'],
})
.notNull()
.default('place-value'),
/** Bead shape */
beadShape: (0, sqlite_core_1.text)('bead_shape', {
enum: ['diamond', 'circle', 'square'],
})
.notNull()
.default('diamond'),
/** Color palette */
colorPalette: (0, sqlite_core_1.text)('color_palette', {
enum: ['default', 'colorblind', 'mnemonic', 'grayscale', 'nature'],
})
.notNull()
.default('default'),
/** Hide inactive beads */
hideInactiveBeads: (0, sqlite_core_1.integer)('hide_inactive_beads', { mode: 'boolean' }).notNull().default(false),
/** Color numerals based on place value */
coloredNumerals: (0, sqlite_core_1.integer)('colored_numerals', { mode: 'boolean' }).notNull().default(false),
/** Scale factor for abacus size */
scaleFactor: (0, sqlite_core_1.real)('scale_factor').notNull().default(1.0),
/** Show numbers below abacus */
showNumbers: (0, sqlite_core_1.integer)('show_numbers', { mode: 'boolean' }).notNull().default(true),
/** Enable animations */
animated: (0, sqlite_core_1.integer)('animated', { mode: 'boolean' }).notNull().default(true),
/** Enable interaction */
interactive: (0, sqlite_core_1.integer)('interactive', { mode: 'boolean' }).notNull().default(false),
/** Enable gesture controls */
gestures: (0, sqlite_core_1.integer)('gestures', { mode: 'boolean' }).notNull().default(false),
/** Enable sound effects */
soundEnabled: (0, sqlite_core_1.integer)('sound_enabled', { mode: 'boolean' }).notNull().default(true),
/** Sound volume (0.0 - 1.0) */
soundVolume: (0, sqlite_core_1.real)('sound_volume').notNull().default(0.8),
});

View File

@@ -1,39 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.arcadeRooms = void 0;
const cuid2_1 = require("@paralleldrive/cuid2");
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
exports.arcadeRooms = (0, sqlite_core_1.sqliteTable)('arcade_rooms', {
id: (0, sqlite_core_1.text)('id')
.primaryKey()
.$defaultFn(() => (0, cuid2_1.createId)()),
// Room identity
code: (0, sqlite_core_1.text)('code', { length: 6 }).notNull().unique(), // e.g., "ABC123"
name: (0, sqlite_core_1.text)('name', { length: 50 }).notNull(),
// Creator info
createdBy: (0, sqlite_core_1.text)('created_by').notNull(), // User/guest ID
creatorName: (0, sqlite_core_1.text)('creator_name', { length: 50 }).notNull(),
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
// Lifecycle
lastActivity: (0, sqlite_core_1.integer)('last_activity', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
ttlMinutes: (0, sqlite_core_1.integer)('ttl_minutes').notNull().default(60), // Time to live
isLocked: (0, sqlite_core_1.integer)('is_locked', { mode: 'boolean' }).notNull().default(false),
// Game configuration
gameName: (0, sqlite_core_1.text)('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
gameConfig: (0, sqlite_core_1.text)('game_config', { mode: 'json' }).notNull(), // Game-specific settings
// Current state
status: (0, sqlite_core_1.text)('status', {
enum: ['lobby', 'playing', 'finished'],
})
.notNull()
.default('lobby'),
currentSessionId: (0, sqlite_core_1.text)('current_session_id'), // FK to arcade_sessions (nullable)
// Metadata
totalGamesPlayed: (0, sqlite_core_1.integer)('total_games_played').notNull().default(0),
});

View File

@@ -1,30 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.arcadeSessions = void 0;
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const arcade_rooms_1 = require("./arcade-rooms");
const users_1 = require("./users");
exports.arcadeSessions = (0, sqlite_core_1.sqliteTable)('arcade_sessions', {
userId: (0, sqlite_core_1.text)('user_id')
.primaryKey()
.references(() => users_1.users.id, { onDelete: 'cascade' }),
// Session metadata
currentGame: (0, sqlite_core_1.text)('current_game', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
gameUrl: (0, sqlite_core_1.text)('game_url').notNull(), // e.g., '/arcade/matching'
// Game state (JSON blob)
gameState: (0, sqlite_core_1.text)('game_state', { mode: 'json' }).notNull(),
// Active players snapshot (for quick access)
activePlayers: (0, sqlite_core_1.text)('active_players', { mode: 'json' }).notNull(),
// Room association (null for solo play)
roomId: (0, sqlite_core_1.text)('room_id').references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'set null' }),
// Timing & TTL
startedAt: (0, sqlite_core_1.integer)('started_at', { mode: 'timestamp' }).notNull(),
lastActivityAt: (0, sqlite_core_1.integer)('last_activity_at', { mode: 'timestamp' }).notNull(),
expiresAt: (0, sqlite_core_1.integer)('expires_at', { mode: 'timestamp' }).notNull(), // TTL-based
// Status
isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(true),
// Version for optimistic locking
version: (0, sqlite_core_1.integer)('version').notNull().default(1),
});

View File

@@ -1,29 +0,0 @@
"use strict";
/**
* Database schema exports
*
* This is the single source of truth for the database schema.
* All tables, relations, and types are exported from here.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./abacus-settings"), exports);
__exportStar(require("./arcade-rooms"), exports);
__exportStar(require("./arcade-sessions"), exports);
__exportStar(require("./players"), exports);
__exportStar(require("./room-members"), exports);
__exportStar(require("./user-stats"), exports);
__exportStar(require("./users"), exports);

View File

@@ -1,36 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.players = void 0;
const cuid2_1 = require("@paralleldrive/cuid2");
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const users_1 = require("./users");
/**
* Players table - user-created player profiles for games
*
* Each user can have multiple players (for multi-player modes).
* Players are scoped to a user and deleted when user is deleted.
*/
exports.players = (0, sqlite_core_1.sqliteTable)('players', {
id: (0, sqlite_core_1.text)('id')
.primaryKey()
.$defaultFn(() => (0, cuid2_1.createId)()),
/** Foreign key to users table - cascades on delete */
userId: (0, sqlite_core_1.text)('user_id')
.notNull()
.references(() => users_1.users.id, { onDelete: 'cascade' }),
/** Player display name */
name: (0, sqlite_core_1.text)('name').notNull(),
/** Player emoji avatar */
emoji: (0, sqlite_core_1.text)('emoji').notNull(),
/** Player color (hex) for UI theming */
color: (0, sqlite_core_1.text)('color').notNull(),
/** Whether this player is currently active in games */
isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(false),
/** When this player was created */
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
}, (table) => ({
/** Index for fast lookups by userId */
userIdIdx: (0, sqlite_core_1.index)('players_user_id_idx').on(table.userId),
}));

View File

@@ -1,27 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.roomMembers = void 0;
const cuid2_1 = require("@paralleldrive/cuid2");
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const arcade_rooms_1 = require("./arcade-rooms");
exports.roomMembers = (0, sqlite_core_1.sqliteTable)('room_members', {
id: (0, sqlite_core_1.text)('id')
.primaryKey()
.$defaultFn(() => (0, cuid2_1.createId)()),
roomId: (0, sqlite_core_1.text)('room_id')
.notNull()
.references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'cascade' }),
userId: (0, sqlite_core_1.text)('user_id').notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below)
displayName: (0, sqlite_core_1.text)('display_name', { length: 50 }).notNull(),
isCreator: (0, sqlite_core_1.integer)('is_creator', { mode: 'boolean' }).notNull().default(false),
joinedAt: (0, sqlite_core_1.integer)('joined_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
lastSeen: (0, sqlite_core_1.integer)('last_seen', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
isOnline: (0, sqlite_core_1.integer)('is_online', { mode: 'boolean' }).notNull().default(true),
}, (table) => ({
// Explicit unique index for clarity and database-level enforcement
userIdIdx: (0, sqlite_core_1.uniqueIndex)('idx_room_members_user_id_unique').on(table.userId),
}));

View File

@@ -1,29 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.userStats = void 0;
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
const users_1 = require("./users");
/**
* User stats table - game statistics per user
*
* One-to-one with users table. Tracks aggregate game performance.
* Deleted when user is deleted (cascade).
*/
exports.userStats = (0, sqlite_core_1.sqliteTable)('user_stats', {
/** Primary key and foreign key to users table */
userId: (0, sqlite_core_1.text)('user_id')
.primaryKey()
.references(() => users_1.users.id, { onDelete: 'cascade' }),
/** Total number of games played */
gamesPlayed: (0, sqlite_core_1.integer)('games_played').notNull().default(0),
/** Total number of games won */
totalWins: (0, sqlite_core_1.integer)('total_wins').notNull().default(0),
/** User's most-played game type */
favoriteGameType: (0, sqlite_core_1.text)('favorite_game_type', {
enum: ['abacus-numeral', 'complement-pairs'],
}),
/** Best completion time in milliseconds */
bestTime: (0, sqlite_core_1.integer)('best_time'),
/** Highest accuracy percentage (0.0 - 1.0) */
highestAccuracy: (0, sqlite_core_1.real)('highest_accuracy').notNull().default(0),
});

View File

@@ -1,28 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.users = void 0;
const cuid2_1 = require("@paralleldrive/cuid2");
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
/**
* Users table - stores both guest and authenticated users
*
* Guest users are created automatically on first visit via middleware.
* They can upgrade to full accounts later while preserving their data.
*/
exports.users = (0, sqlite_core_1.sqliteTable)('users', {
id: (0, sqlite_core_1.text)('id')
.primaryKey()
.$defaultFn(() => (0, cuid2_1.createId)()),
/** Stable guest ID from HttpOnly cookie - unique per browser session */
guestId: (0, sqlite_core_1.text)('guest_id').notNull().unique(),
/** When this user record was created */
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
/** When guest upgraded to full account (null for guests) */
upgradedAt: (0, sqlite_core_1.integer)('upgraded_at', { mode: 'timestamp' }),
/** Email (only set after upgrade) */
email: (0, sqlite_core_1.text)('email').unique(),
/** Display name (only set after upgrade) */
name: (0, sqlite_core_1.text)('name'),
});

View File

@@ -48,9 +48,8 @@ export interface UseArcadeSessionReturn<TState> {
/**
* Send a game move (applies optimistically and sends to server)
* Note: playerId must be provided by caller (not omitted)
*/
sendMove: (move: Omit<GameMove, 'timestamp'>) => void
sendMove: (move: Omit<GameMove, 'playerId' | 'timestamp'>) => void
/**
* Exit the arcade session
@@ -150,10 +149,10 @@ export function useArcadeSession<TState>(
// Apply optimistically
optimistic.applyOptimisticMove(fullMove)
// Send to server with roomId for room-based games
socketSendMove(userId, fullMove, roomId)
// Send to server
socketSendMove(userId, fullMove)
},
[userId, roomId, optimistic, socketSendMove]
[userId, optimistic, socketSendMove]
)
const exitSession = useCallback(() => {

View File

@@ -21,7 +21,7 @@ export interface UseArcadeSocketReturn {
socket: Socket | null
connected: boolean
joinSession: (userId: string, roomId?: string) => void
sendMove: (userId: string, move: GameMove, roomId?: string) => void
sendMove: (userId: string, move: GameMove) => void
exitSession: (userId: string) => void
pingSession: (userId: string) => void
}
@@ -119,12 +119,12 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
)
const sendMove = useCallback(
(userId: string, move: GameMove, roomId?: string) => {
(userId: string, move: GameMove) => {
if (!socket) {
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
return
}
const payload = { userId, move, roomId }
const payload = { userId, move }
console.log(
'[ArcadeSocket] Sending game-move event with payload:',
JSON.stringify(payload, null, 2)

View File

@@ -1,120 +0,0 @@
"use strict";
/**
* Player manager for arcade rooms
* Handles fetching and validating player participation in rooms
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getAllPlayers = getAllPlayers;
exports.getActivePlayers = getActivePlayers;
exports.getRoomActivePlayers = getRoomActivePlayers;
exports.getRoomPlayerIds = getRoomPlayerIds;
exports.validatePlayerInRoom = validatePlayerInRoom;
exports.getPlayer = getPlayer;
exports.getPlayers = getPlayers;
const drizzle_orm_1 = require("drizzle-orm");
const db_1 = require("../../db");
/**
* Get all players for a user (regardless of isActive status)
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
*/
async function getAllPlayers(viewerId) {
// First get the user record by guestId
const user = await db_1.db.query.users.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, viewerId),
});
if (!user) {
return [];
}
// Now query all players by the actual user.id (no isActive filter)
return await db_1.db.query.players.findMany({
where: (0, drizzle_orm_1.eq)(db_1.schema.players.userId, user.id),
orderBy: db_1.schema.players.createdAt,
});
}
/**
* Get a user's active players (solo mode)
* These are the players that will participate when the user joins a solo game
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
*/
async function getActivePlayers(viewerId) {
// First get the user record by guestId
const user = await db_1.db.query.users.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, viewerId),
});
if (!user) {
return [];
}
// Now query players by the actual user.id
return await db_1.db.query.players.findMany({
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.players.userId, user.id), (0, drizzle_orm_1.eq)(db_1.schema.players.isActive, true)),
orderBy: db_1.schema.players.createdAt,
});
}
/**
* Get active players for all members in a room
* Returns only players marked isActive=true from each room member
* Returns a map of userId -> Player[]
*/
async function getRoomActivePlayers(roomId) {
// Get all room members
const members = await db_1.db.query.roomMembers.findMany({
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId),
});
// Fetch active players for each member (respects isActive flag)
const playerMap = new Map();
for (const member of members) {
const players = await getActivePlayers(member.userId);
playerMap.set(member.userId, players);
}
return playerMap;
}
/**
* Get all player IDs that should participate in a room game
* Flattens the player lists from all room members
*/
async function getRoomPlayerIds(roomId) {
const playerMap = await getRoomActivePlayers(roomId);
const allPlayers = [];
for (const players of playerMap.values()) {
allPlayers.push(...players.map((p) => p.id));
}
return allPlayers;
}
/**
* Validate that a player ID belongs to a user who is a member of a room
*/
async function validatePlayerInRoom(playerId, roomId) {
// Get the player
const player = await db_1.db.query.players.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.players.id, playerId),
});
if (!player)
return false;
// Check if the player's user is a member of the room
const member = await db_1.db.query.roomMembers.findFirst({
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, player.userId)),
});
return !!member;
}
/**
* Get player details by ID
*/
async function getPlayer(playerId) {
return await db_1.db.query.players.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.players.id, playerId),
});
}
/**
* Get multiple players by IDs
*/
async function getPlayers(playerIds) {
if (playerIds.length === 0)
return [];
const players = [];
for (const id of playerIds) {
const player = await getPlayer(id);
if (player)
players.push(player);
}
return players;
}

View File

@@ -1,37 +0,0 @@
"use strict";
/**
* Room code generation utility
* Generates short, memorable codes for joining rooms
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateRoomCode = generateRoomCode;
exports.isValidRoomCode = isValidRoomCode;
exports.normalizeRoomCode = normalizeRoomCode;
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed ambiguous chars: 0,O,1,I
const CODE_LENGTH = 6;
/**
* Generate a random 6-character room code
* Format: ABC123 (uppercase letters + numbers, no ambiguous chars)
*/
function generateRoomCode() {
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) {
const randomIndex = Math.floor(Math.random() * CHARS.length);
code += CHARS[randomIndex];
}
return code;
}
/**
* Validate a room code format
*/
function isValidRoomCode(code) {
if (code.length !== CODE_LENGTH)
return false;
return code.split('').every((char) => CHARS.includes(char));
}
/**
* Normalize a room code (uppercase, remove spaces/dashes)
*/
function normalizeRoomCode(code) {
return code.toUpperCase().replace(/[\s-]/g, '');
}

View File

@@ -1,154 +0,0 @@
"use strict";
/**
* Arcade room manager
* Handles database operations for arcade rooms
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createRoom = createRoom;
exports.getRoomById = getRoomById;
exports.getRoomByCode = getRoomByCode;
exports.updateRoom = updateRoom;
exports.touchRoom = touchRoom;
exports.deleteRoom = deleteRoom;
exports.listActiveRooms = listActiveRooms;
exports.cleanupExpiredRooms = cleanupExpiredRooms;
exports.isRoomCreator = isRoomCreator;
const drizzle_orm_1 = require("drizzle-orm");
const db_1 = require("../../db");
const room_code_1 = require("./room-code");
/**
* Create a new arcade room
* Generates a unique room code and creates the room in the database
*/
async function createRoom(options) {
const now = new Date();
// Generate unique room code (retry up to 5 times if collision)
let code = (0, room_code_1.generateRoomCode)();
let attempts = 0;
const MAX_ATTEMPTS = 5;
while (attempts < MAX_ATTEMPTS) {
const existing = await getRoomByCode(code);
if (!existing)
break;
code = (0, room_code_1.generateRoomCode)();
attempts++;
}
if (attempts === MAX_ATTEMPTS) {
throw new Error('Failed to generate unique room code');
}
const newRoom = {
code,
name: options.name,
createdBy: options.createdBy,
creatorName: options.creatorName,
createdAt: now,
lastActivity: now,
ttlMinutes: options.ttlMinutes || 60,
isLocked: false,
gameName: options.gameName,
gameConfig: options.gameConfig,
status: 'lobby',
currentSessionId: null,
totalGamesPlayed: 0,
};
const [room] = await db_1.db.insert(db_1.schema.arcadeRooms).values(newRoom).returning();
console.log('[Room Manager] Created room:', room.id, 'code:', room.code);
return room;
}
/**
* Get a room by ID
*/
async function getRoomById(roomId) {
return await db_1.db.query.arcadeRooms.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId),
});
}
/**
* Get a room by code
*/
async function getRoomByCode(code) {
return await db_1.db.query.arcadeRooms.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.code, code.toUpperCase()),
});
}
/**
* Update a room
*/
async function updateRoom(roomId, updates) {
const now = new Date();
// Always update lastActivity on any room update
const updateData = {
...updates,
lastActivity: now,
};
const [updated] = await db_1.db
.update(db_1.schema.arcadeRooms)
.set(updateData)
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId))
.returning();
return updated;
}
/**
* Update room activity timestamp
* Call this on any room activity to refresh TTL
*/
async function touchRoom(roomId) {
await db_1.db
.update(db_1.schema.arcadeRooms)
.set({ lastActivity: new Date() })
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId));
}
/**
* Delete a room
* Cascade deletes all room members
*/
async function deleteRoom(roomId) {
await db_1.db.delete(db_1.schema.arcadeRooms).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId));
console.log('[Room Manager] Deleted room:', roomId);
}
/**
* List active rooms
* Returns rooms ordered by most recently active
*/
async function listActiveRooms(gameName) {
const whereConditions = [];
// Filter by game if specified
if (gameName) {
whereConditions.push((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.gameName, gameName));
}
// Only return non-locked rooms in lobby or playing status
whereConditions.push((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.isLocked, false), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.status, 'lobby'), (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.status, 'playing')));
return await db_1.db.query.arcadeRooms.findMany({
where: whereConditions.length > 0 ? (0, drizzle_orm_1.and)(...whereConditions) : undefined,
orderBy: [(0, drizzle_orm_1.desc)(db_1.schema.arcadeRooms.lastActivity)],
limit: 50, // Limit to 50 most recent rooms
});
}
/**
* Clean up expired rooms
* Delete rooms that have exceeded their TTL
*/
async function cleanupExpiredRooms() {
const now = new Date();
// Find rooms where lastActivity + ttlMinutes < now
const expiredRooms = await db_1.db.query.arcadeRooms.findMany({
columns: { id: true, ttlMinutes: true, lastActivity: true },
});
const toDelete = expiredRooms.filter((room) => {
const expiresAt = new Date(room.lastActivity.getTime() + room.ttlMinutes * 60 * 1000);
return expiresAt < now;
});
if (toDelete.length > 0) {
const ids = toDelete.map((r) => r.id);
await db_1.db.delete(db_1.schema.arcadeRooms).where((0, drizzle_orm_1.or)(...ids.map((id) => (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, id))));
console.log(`[Room Manager] Cleaned up ${toDelete.length} expired rooms`);
}
return toDelete.length;
}
/**
* Check if a user is the creator of a room
*/
async function isRoomCreator(roomId, userId) {
const room = await getRoomById(roomId);
return room?.createdBy === userId;
}

View File

@@ -1,179 +0,0 @@
"use strict";
/**
* Room membership manager
* Handles database operations for room members
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.addRoomMember = addRoomMember;
exports.getRoomMember = getRoomMember;
exports.getRoomMembers = getRoomMembers;
exports.getOnlineRoomMembers = getOnlineRoomMembers;
exports.setMemberOnline = setMemberOnline;
exports.touchMember = touchMember;
exports.removeMember = removeMember;
exports.removeAllMembers = removeAllMembers;
exports.getOnlineMemberCount = getOnlineMemberCount;
exports.isMember = isMember;
exports.getUserRooms = getUserRooms;
const drizzle_orm_1 = require("drizzle-orm");
const db_1 = require("../../db");
/**
* Add a member to a room
* Automatically removes user from any other rooms they're in (modal room enforcement)
* Returns the new membership and info about rooms that were auto-left
*/
async function addRoomMember(options) {
const now = new Date();
// Check if member already exists in THIS room
const existing = await getRoomMember(options.roomId, options.userId);
if (existing) {
// Already in this room - just update status (no auto-leave needed)
const [updated] = await db_1.db
.update(db_1.schema.roomMembers)
.set({
isOnline: true,
lastSeen: now,
})
.where((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.id, existing.id))
.returning();
return { member: updated };
}
// AUTO-LEAVE LOGIC: Remove from all other rooms before joining this one
const currentRooms = await getUserRooms(options.userId);
const autoLeaveResult = {
leftRooms: [],
previousRoomMembers: [],
};
for (const roomId of currentRooms) {
if (roomId !== options.roomId) {
// Get member info before removing (for socket events)
const memberToRemove = await getRoomMember(roomId, options.userId);
if (memberToRemove) {
autoLeaveResult.previousRoomMembers.push({
roomId,
member: memberToRemove,
});
}
// Remove from room
await removeMember(roomId, options.userId);
autoLeaveResult.leftRooms.push(roomId);
console.log(`[Room Membership] Auto-left room ${roomId} for user ${options.userId}`);
}
}
// Now add to new room
const newMember = {
roomId: options.roomId,
userId: options.userId,
displayName: options.displayName,
isCreator: options.isCreator || false,
joinedAt: now,
lastSeen: now,
isOnline: true,
};
try {
const [member] = await db_1.db.insert(db_1.schema.roomMembers).values(newMember).returning();
console.log('[Room Membership] Added member:', member.userId, 'to room:', member.roomId);
return {
member,
autoLeaveResult: autoLeaveResult.leftRooms.length > 0 ? autoLeaveResult : undefined,
};
}
catch (error) {
// Handle unique constraint violation
// This should rarely happen due to auto-leave logic above, but catch it for safety
if (error.code === 'SQLITE_CONSTRAINT' ||
error.message?.includes('UNIQUE') ||
error.message?.includes('unique')) {
console.error('[Room Membership] Unique constraint violation:', error.message);
throw new Error('ROOM_MEMBERSHIP_CONFLICT: User is already in another room. This should have been handled by auto-leave logic.');
}
throw error;
}
}
/**
* Get a specific room member
*/
async function getRoomMember(roomId, userId) {
return await db_1.db.query.roomMembers.findFirst({
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)),
});
}
/**
* Get all members in a room
*/
async function getRoomMembers(roomId) {
return await db_1.db.query.roomMembers.findMany({
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId),
orderBy: db_1.schema.roomMembers.joinedAt,
});
}
/**
* Get online members in a room
*/
async function getOnlineRoomMembers(roomId) {
return await db_1.db.query.roomMembers.findMany({
where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.isOnline, true)),
orderBy: db_1.schema.roomMembers.joinedAt,
});
}
/**
* Update member's online status
*/
async function setMemberOnline(roomId, userId, isOnline) {
await db_1.db
.update(db_1.schema.roomMembers)
.set({
isOnline,
lastSeen: new Date(),
})
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
}
/**
* Update member's last seen timestamp
*/
async function touchMember(roomId, userId) {
await db_1.db
.update(db_1.schema.roomMembers)
.set({ lastSeen: new Date() })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
}
/**
* Remove a member from a room
*/
async function removeMember(roomId, userId) {
await db_1.db
.delete(db_1.schema.roomMembers)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)));
console.log('[Room Membership] Removed member:', userId, 'from room:', roomId);
}
/**
* Remove all members from a room
*/
async function removeAllMembers(roomId) {
await db_1.db.delete(db_1.schema.roomMembers).where((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId));
console.log('[Room Membership] Removed all members from room:', roomId);
}
/**
* Get count of online members in a room
*/
async function getOnlineMemberCount(roomId) {
const members = await getOnlineRoomMembers(roomId);
return members.length;
}
/**
* Check if a user is a member of a room
*/
async function isMember(roomId, userId) {
const member = await getRoomMember(roomId, userId);
return !!member;
}
/**
* Get all rooms a user is a member of
*/
async function getUserRooms(userId) {
const memberships = await db_1.db.query.roomMembers.findMany({
where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId),
columns: { roomId: true },
});
return memberships.map((m) => m.roomId);
}

View File

@@ -1,55 +0,0 @@
"use strict";
/**
* Room TTL Cleanup Scheduler
* Periodically cleans up expired rooms
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.startRoomTTLCleanup = startRoomTTLCleanup;
exports.stopRoomTTLCleanup = stopRoomTTLCleanup;
const room_manager_1 = require("./room-manager");
// Cleanup interval: run every 5 minutes
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
let cleanupInterval = null;
/**
* Start the TTL cleanup scheduler
* Runs cleanup every 5 minutes
*/
function startRoomTTLCleanup() {
if (cleanupInterval) {
console.log('[Room TTL] Cleanup scheduler already running');
return;
}
console.log('[Room TTL] Starting cleanup scheduler (every 5 minutes)');
// Run immediately on start
(0, room_manager_1.cleanupExpiredRooms)()
.then((count) => {
if (count > 0) {
console.log(`[Room TTL] Initial cleanup removed ${count} expired rooms`);
}
})
.catch((error) => {
console.error('[Room TTL] Initial cleanup failed:', error);
});
// Then run periodically
cleanupInterval = setInterval(async () => {
try {
const count = await (0, room_manager_1.cleanupExpiredRooms)();
if (count > 0) {
console.log(`[Room TTL] Cleanup removed ${count} expired rooms`);
}
}
catch (error) {
console.error('[Room TTL] Cleanup failed:', error);
}
}, CLEANUP_INTERVAL_MS);
}
/**
* Stop the TTL cleanup scheduler
*/
function stopRoomTTLCleanup() {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
console.log('[Room TTL] Cleanup scheduler stopped');
}
}

View File

@@ -1,296 +0,0 @@
"use strict";
/**
* Arcade session manager
* Handles database operations and validation for arcade sessions
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getArcadeSessionByRoom = getArcadeSessionByRoom;
exports.createArcadeSession = createArcadeSession;
exports.getArcadeSession = getArcadeSession;
exports.applyGameMove = applyGameMove;
exports.deleteArcadeSession = deleteArcadeSession;
exports.updateSessionActivity = updateSessionActivity;
exports.cleanupExpiredSessions = cleanupExpiredSessions;
const drizzle_orm_1 = require("drizzle-orm");
const db_1 = require("../../db");
const validation_1 = require("./validation");
const TTL_HOURS = 24;
/**
* Helper: Get database user ID from guest ID
* The API uses guestId (from cookies) but database FKs use the internal user.id
*/
async function getUserIdFromGuestId(guestId) {
const user = await db_1.db.query.users.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, guestId),
columns: { id: true },
});
return user?.id;
}
/**
* Get arcade session by room ID (for room-based multiplayer games)
* Returns the shared session for all room members
* @param roomId - The room ID
*/
async function getArcadeSessionByRoom(roomId) {
const [session] = await db_1.db
.select()
.from(db_1.schema.arcadeSessions)
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.roomId, roomId))
.limit(1);
if (!session)
return undefined;
// Check if session has expired
if (session.expiresAt < new Date()) {
// Clean up expired room session
await db_1.db.delete(db_1.schema.arcadeSessions).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.roomId, roomId));
return undefined;
}
return session;
}
/**
* Create a new arcade session
* For room-based games, checks if a session already exists for the room
*/
async function createArcadeSession(options) {
const now = new Date();
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
// For room-based games, check if session already exists for this room
if (options.roomId) {
const existingRoomSession = await getArcadeSessionByRoom(options.roomId);
if (existingRoomSession) {
console.log('[Session Manager] Room session already exists, returning existing:', {
roomId: options.roomId,
sessionUserId: existingRoomSession.userId,
version: existingRoomSession.version,
});
return existingRoomSession;
}
}
// Find or create user by guest ID
let user = await db_1.db.query.users.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, options.userId),
});
if (!user) {
console.log('[Session Manager] Creating new user with guestId:', options.userId);
const [newUser] = await db_1.db
.insert(db_1.schema.users)
.values({
guestId: options.userId, // Let id auto-generate via $defaultFn
createdAt: now,
})
.returning();
user = newUser;
console.log('[Session Manager] Created user with id:', user.id);
}
else {
console.log('[Session Manager] Found existing user with id:', user.id);
}
const newSession = {
userId: user.id, // Use the actual database ID, not the guestId
currentGame: options.gameName,
gameUrl: options.gameUrl,
gameState: options.initialState,
activePlayers: options.activePlayers,
roomId: options.roomId, // Associate session with room
startedAt: now,
lastActivityAt: now,
expiresAt,
isActive: true,
version: 1,
};
console.log('[Session Manager] Creating new session:', {
userId: user.id,
roomId: options.roomId,
gameName: options.gameName,
});
const [session] = await db_1.db.insert(db_1.schema.arcadeSessions).values(newSession).returning();
return session;
}
/**
* Get active arcade session for a user
* @param guestId - The guest ID from the cookie (not the database user.id)
*/
async function getArcadeSession(guestId) {
const userId = await getUserIdFromGuestId(guestId);
if (!userId)
return undefined;
const [session] = await db_1.db
.select()
.from(db_1.schema.arcadeSessions)
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId))
.limit(1);
if (!session)
return undefined;
// Check if session has expired
if (session.expiresAt < new Date()) {
await deleteArcadeSession(guestId);
return undefined;
}
// Check if session has a valid room association
// Sessions without rooms are orphaned and should be cleaned up
if (!session.roomId) {
console.log('[Session Manager] Deleting orphaned session without room:', session.userId);
await deleteArcadeSession(guestId);
return undefined;
}
// Verify the room still exists
const room = await db_1.db.query.arcadeRooms.findFirst({
where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, session.roomId),
});
if (!room) {
console.log('[Session Manager] Deleting session with non-existent room:', session.roomId);
await deleteArcadeSession(guestId);
return undefined;
}
return session;
}
/**
* Apply a game move to the session (with validation)
* @param userId - The guest ID from the cookie
* @param move - The game move to apply
* @param roomId - Optional room ID for room-based games (enables shared session)
*/
async function applyGameMove(userId, move, roomId) {
// For room-based games, look up the shared room session
// For solo games, look up the user's personal session
const session = roomId ? await getArcadeSessionByRoom(roomId) : await getArcadeSession(userId);
if (!session) {
return {
success: false,
error: 'No active session found',
};
}
if (!session.isActive) {
return {
success: false,
error: 'Session is not active',
};
}
// Get the validator for this game
const validator = (0, validation_1.getValidator)(session.currentGame);
console.log('[SessionManager] About to validate move:', {
moveType: move.type,
playerId: move.playerId,
gameStateCurrentPlayer: session.gameState?.currentPlayer,
gameStateActivePlayers: session.gameState?.activePlayers,
gameStatePhase: session.gameState?.gamePhase,
});
// Fetch player ownership for authorization checks (room-based games)
let playerOwnership;
let internalUserId;
if (session.roomId) {
try {
// Convert guestId to internal userId for ownership comparison
internalUserId = await getUserIdFromGuestId(userId);
if (!internalUserId) {
console.error('[SessionManager] Failed to convert guestId to userId:', userId);
return {
success: false,
error: 'User not found',
};
}
const players = await db_1.db.query.players.findMany({
columns: {
id: true,
userId: true,
},
});
playerOwnership = Object.fromEntries(players.map((p) => [p.id, p.userId]));
console.log('[SessionManager] Player ownership map:', playerOwnership);
console.log('[SessionManager] Internal userId for authorization:', internalUserId);
}
catch (error) {
console.error('[SessionManager] Failed to fetch player ownership:', error);
}
}
// Validate the move with authorization context (use internal userId, not guestId)
const validationResult = validator.validateMove(session.gameState, move, {
userId: internalUserId || userId, // Use internal userId for room-based games
playerOwnership,
});
console.log('[SessionManager] Validation result:', {
valid: validationResult.valid,
error: validationResult.error,
});
if (!validationResult.valid) {
return {
success: false,
error: validationResult.error || 'Invalid move',
};
}
// Update the session with new state (using optimistic locking)
const now = new Date();
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
try {
const [updatedSession] = await db_1.db
.update(db_1.schema.arcadeSessions)
.set({
gameState: validationResult.newState,
lastActivityAt: now,
expiresAt,
version: session.version + 1,
})
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, session.userId) // Use the userId from the session we just fetched
)
// Version check for optimistic locking would go here
// SQLite doesn't support WHERE clauses in UPDATE with RETURNING easily
// We'll handle this by checking the version after
.returning();
if (!updatedSession) {
return {
success: false,
error: 'Failed to update session',
};
}
return {
success: true,
session: updatedSession,
};
}
catch (error) {
console.error('Error updating session:', error);
return {
success: false,
error: 'Database error',
};
}
}
/**
* Delete an arcade session
* @param guestId - The guest ID from the cookie (not the database user.id)
*/
async function deleteArcadeSession(guestId) {
const userId = await getUserIdFromGuestId(guestId);
if (!userId)
return;
await db_1.db.delete(db_1.schema.arcadeSessions).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId));
}
/**
* Update session activity timestamp (keep-alive)
* @param guestId - The guest ID from the cookie (not the database user.id)
*/
async function updateSessionActivity(guestId) {
const userId = await getUserIdFromGuestId(guestId);
if (!userId)
return;
const now = new Date();
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000);
await db_1.db
.update(db_1.schema.arcadeSessions)
.set({
lastActivityAt: now,
expiresAt,
})
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId));
}
/**
* Clean up expired sessions (should be called periodically)
*/
async function cleanupExpiredSessions() {
const now = new Date();
const result = await db_1.db
.delete(db_1.schema.arcadeSessions)
.where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.expiresAt, now))
.returning();
return result.length;
}

View File

@@ -37,35 +37,8 @@ async function getUserIdFromGuestId(guestId: string): Promise<string | undefined
return user?.id
}
/**
* Get arcade session by room ID (for room-based multiplayer games)
* Returns the shared session for all room members
* @param roomId - The room ID
*/
export async function getArcadeSessionByRoom(
roomId: string
): Promise<schema.ArcadeSession | undefined> {
const [session] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.roomId, roomId))
.limit(1)
if (!session) return undefined
// Check if session has expired
if (session.expiresAt < new Date()) {
// Clean up expired room session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId))
return undefined
}
return session
}
/**
* Create a new arcade session
* For room-based games, checks if a session already exists for the room
*/
export async function createArcadeSession(
options: CreateSessionOptions
@@ -73,19 +46,6 @@ export async function createArcadeSession(
const now = new Date()
const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000)
// For room-based games, check if session already exists for this room
if (options.roomId) {
const existingRoomSession = await getArcadeSessionByRoom(options.roomId)
if (existingRoomSession) {
console.log('[Session Manager] Room session already exists, returning existing:', {
roomId: options.roomId,
sessionUserId: existingRoomSession.userId,
version: existingRoomSession.version,
})
return existingRoomSession
}
}
// Find or create user by guest ID
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, options.userId),
@@ -120,12 +80,6 @@ export async function createArcadeSession(
version: 1,
}
console.log('[Session Manager] Creating new session:', {
userId: user.id,
roomId: options.roomId,
gameName: options.gameName,
})
const [session] = await db.insert(schema.arcadeSessions).values(newSession).returning()
return session
}
@@ -176,18 +130,9 @@ export async function getArcadeSession(guestId: string): Promise<schema.ArcadeSe
/**
* Apply a game move to the session (with validation)
* @param userId - The guest ID from the cookie
* @param move - The game move to apply
* @param roomId - Optional room ID for room-based games (enables shared session)
*/
export async function applyGameMove(
userId: string,
move: GameMove,
roomId?: string
): Promise<SessionUpdateResult> {
// For room-based games, look up the shared room session
// For solo games, look up the user's personal session
const session = roomId ? await getArcadeSessionByRoom(roomId) : await getArcadeSession(userId)
export async function applyGameMove(userId: string, move: GameMove): Promise<SessionUpdateResult> {
const session = await getArcadeSession(userId)
if (!session) {
return {

View File

@@ -1,469 +0,0 @@
"use strict";
/**
* Server-side validator for matching game
* Validates all game moves and state transitions
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.matchingGameValidator = exports.MatchingGameValidator = void 0;
const cardGeneration_1 = require("../../../app/games/matching/utils/cardGeneration");
const matchValidation_1 = require("../../../app/games/matching/utils/matchValidation");
class MatchingGameValidator {
validateMove(state, move, context) {
switch (move.type) {
case 'FLIP_CARD':
return this.validateFlipCard(state, move.data.cardId, move.playerId, context);
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.cards, move.data.playerMetadata);
case 'CLEAR_MISMATCH':
return this.validateClearMismatch(state);
case 'GO_TO_SETUP':
return this.validateGoToSetup(state);
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value);
case 'RESUME_GAME':
return this.validateResumeGame(state);
case 'HOVER_CARD':
return this.validateHoverCard(state, move.data.cardId, move.playerId);
default:
return {
valid: false,
error: `Unknown move type: ${move.type}`,
};
}
}
validateFlipCard(state, cardId, playerId, context) {
// Game must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Cannot flip cards outside of playing phase',
};
}
// Check if it's the player's turn (in multiplayer)
if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) {
console.log('[Validator] Turn check failed:', {
activePlayers: state.activePlayers,
currentPlayer: state.currentPlayer,
currentPlayerType: typeof state.currentPlayer,
playerId,
playerIdType: typeof playerId,
matches: state.currentPlayer === playerId,
});
return {
valid: false,
error: 'Not your turn',
};
}
// Check player ownership authorization (if context provided)
if (context?.userId && context?.playerOwnership) {
const playerOwner = context.playerOwnership[playerId];
if (playerOwner && playerOwner !== context.userId) {
console.log('[Validator] Player ownership check failed:', {
playerId,
playerOwner,
requestingUserId: context.userId,
});
return {
valid: false,
error: 'You can only move your own players',
};
}
}
// Find the card
const card = state.gameCards.find((c) => c.id === cardId);
if (!card) {
return {
valid: false,
error: 'Card not found',
};
}
// Validate using existing game logic
if (!(0, matchValidation_1.canFlipCard)(card, state.flippedCards, state.isProcessingMove)) {
return {
valid: false,
error: 'Cannot flip this card',
};
}
// Calculate new state
const newFlippedCards = [...state.flippedCards, card];
let newState = {
...state,
flippedCards: newFlippedCards,
isProcessingMove: newFlippedCards.length === 2,
// Clear mismatch feedback when player flips a new card
showMismatchFeedback: false,
};
// If two cards are flipped, check for match
if (newFlippedCards.length === 2) {
const [card1, card2] = newFlippedCards;
const matchResult = (0, matchValidation_1.validateMatch)(card1, card2);
if (matchResult.isValid) {
// Match found - update cards
newState = {
...newState,
gameCards: newState.gameCards.map((c) => c.id === card1.id || c.id === card2.id
? { ...c, matched: true, matchedBy: state.currentPlayer }
: c),
matchedPairs: state.matchedPairs + 1,
scores: {
...state.scores,
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
},
consecutiveMatches: {
...state.consecutiveMatches,
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
},
moves: state.moves + 1,
flippedCards: [],
isProcessingMove: false,
};
// Check if game is complete
if (newState.matchedPairs === newState.totalPairs) {
newState = {
...newState,
gamePhase: 'results',
gameEndTime: Date.now(),
};
}
}
else {
// Match failed - keep cards flipped briefly so player can see them
// Client will handle clearing them after a delay
const shouldSwitchPlayer = state.activePlayers.length > 1;
const nextPlayerIndex = shouldSwitchPlayer
? (state.activePlayers.indexOf(state.currentPlayer) + 1) % state.activePlayers.length
: 0;
const nextPlayer = shouldSwitchPlayer
? state.activePlayers[nextPlayerIndex]
: state.currentPlayer;
newState = {
...newState,
currentPlayer: nextPlayer,
consecutiveMatches: {
...state.consecutiveMatches,
[state.currentPlayer]: 0,
},
moves: state.moves + 1,
// Keep flippedCards so player can see both cards
flippedCards: newFlippedCards,
isProcessingMove: true, // Keep processing state so no more cards can be flipped
showMismatchFeedback: true,
};
}
}
return {
valid: true,
newState,
};
}
validateStartGame(state, activePlayers, cards, playerMetadata) {
// Allow starting a new game from any phase (for "New Game" button)
// Must have at least one player
if (!activePlayers || activePlayers.length === 0) {
return {
valid: false,
error: 'Must have at least one player',
};
}
// Use provided cards or generate new ones
const gameCards = cards || (0, cardGeneration_1.generateGameCards)(state.gameType, state.difficulty);
const newState = {
...state,
gameCards,
cards: gameCards,
activePlayers,
playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
gamePhase: 'playing',
gameStartTime: Date.now(),
currentPlayer: activePlayers[0],
flippedCards: [],
matchedPairs: 0,
moves: 0,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
// PAUSE/RESUME: Save original config so we can detect changes
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
// Clear any paused game state (starting fresh)
pausedGamePhase: undefined,
pausedGameState: undefined,
};
return {
valid: true,
newState,
};
}
validateClearMismatch(state) {
// Only clear if there's actually a mismatch showing
// This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
// Nothing to clear - return current state unchanged
return {
valid: true,
newState: state,
};
}
// Clear mismatched cards and feedback
return {
valid: true,
newState: {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
},
};
}
/**
* STANDARD ARCADE PATTERN: GO_TO_SETUP
*
* Transitions the game back to setup phase, allowing players to reconfigure
* the game. This is synchronized across all room members.
*
* Can be called from any phase (setup, playing, results).
*
* PAUSE/RESUME: If called from 'playing' or 'results', saves game state
* to allow resuming later (if config unchanged).
*
* Pattern for all arcade games:
* - Validates the move is allowed
* - Sets gamePhase to 'setup'
* - Preserves current configuration (gameType, difficulty, etc.)
* - Saves game state for resume if coming from active game
* - Resets game progression state (scores, cards, etc.)
*/
validateGoToSetup(state) {
// Determine if we're pausing an active game (for Resume functionality)
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results';
return {
valid: true,
newState: {
...state,
gamePhase: 'setup',
// Pause/Resume: Save game state if pausing from active game
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
gameCards: state.gameCards,
currentPlayer: state.currentPlayer,
matchedPairs: state.matchedPairs,
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
// Keep originalConfig if it exists (was set when game started)
// This allows detecting if config changed while paused
// Reset visible game progression
gameCards: [],
cards: [],
flippedCards: [],
currentPlayer: '',
matchedPairs: 0,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// Preserve configuration - players can modify in setup
// gameType, difficulty, turnTimer stay as-is
},
};
}
/**
* STANDARD ARCADE PATTERN: SET_CONFIG
*
* Updates a configuration field during setup phase. This is synchronized
* across all room members in real-time, allowing collaborative setup.
*
* Pattern for all arcade games:
* - Only allowed during setup phase
* - Validates field name and value
* - Updates the configuration field
* - Other room members see the change immediately (optimistic + server validation)
*
* @param state Current game state
* @param field Configuration field name
* @param value New value for the field
*/
validateSetConfig(state, field, value) {
// 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 'gameType':
if (value !== 'abacus-numeral' && value !== 'complement-pairs') {
return { valid: false, error: `Invalid gameType: ${value}` };
}
break;
case 'difficulty':
if (![6, 8, 12, 15].includes(value)) {
return { valid: false, error: `Invalid difficulty: ${value}` };
}
break;
case 'turnTimer':
if (typeof value !== 'number' || value < 5 || value > 300) {
return { valid: false, error: `Invalid turnTimer: ${value}` };
}
break;
default:
return { valid: false, error: `Unknown config field: ${field}` };
}
// PAUSE/RESUME: If there's a paused game and config is changing,
// clear the paused game state (can't resume anymore)
const clearPausedGame = !!state.pausedGamePhase;
// Apply the configuration change
return {
valid: true,
newState: {
...state,
[field]: value,
// Update totalPairs if difficulty changes
...(field === 'difficulty' ? { totalPairs: value } : {}),
// Clear paused game if config changed
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
},
};
}
/**
* STANDARD ARCADE PATTERN: RESUME_GAME
*
* Resumes a paused game if configuration hasn't changed.
* Restores the saved game state from when GO_TO_SETUP was called.
*
* Pattern for all arcade games:
* - Validates there's a paused game
* - Validates config hasn't changed since pause
* - Restores game state and phase
* - Clears paused game state
*/
validateResumeGame(state) {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only resume from setup phase',
};
}
// Must have a paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return {
valid: false,
error: 'No paused game to resume',
};
}
// Config must match original (no changes while paused)
if (state.originalConfig) {
const configChanged = state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer;
if (configChanged) {
return {
valid: false,
error: 'Cannot resume - configuration has changed',
};
}
}
// Restore the paused game
return {
valid: true,
newState: {
...state,
gamePhase: state.pausedGamePhase,
gameCards: state.pausedGameState.gameCards,
cards: state.pausedGameState.gameCards,
currentPlayer: state.pausedGameState.currentPlayer,
matchedPairs: state.pausedGameState.matchedPairs,
moves: state.pausedGameState.moves,
scores: state.pausedGameState.scores,
activePlayers: state.pausedGameState.activePlayers,
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
// Clear paused state
pausedGamePhase: undefined,
pausedGameState: undefined,
// Keep originalConfig for potential future pauses
},
};
}
/**
* Validate hover state update for networked presence
*
* Hover moves are lightweight and always valid - they just update
* which card a player is hovering over for UI feedback to other players.
*/
validateHoverCard(state, cardId, playerId) {
// Hover is always valid - it's just UI state for networked presence
// Update the player's hover state
return {
valid: true,
newState: {
...state,
playerHovers: {
...state.playerHovers,
[playerId]: cardId,
},
},
};
}
isGameComplete(state) {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs;
}
getInitialState(config) {
return {
cards: [],
gameCards: [],
flippedCards: [],
gameType: config.gameType,
difficulty: config.difficulty,
turnTimer: config.turnTimer,
gamePhase: 'setup',
currentPlayer: '',
matchedPairs: 0,
totalPairs: config.difficulty,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Initialize empty player metadata
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
timerInterval: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Initialize paused game fields
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
};
}
}
exports.MatchingGameValidator = MatchingGameValidator;
// Singleton instance
exports.matchingGameValidator = new MatchingGameValidator();

View File

@@ -25,28 +25,11 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
case 'START_GAME':
return this.validateStartGame(
state,
move.data.activePlayers,
move.data.cards,
move.data.playerMetadata
)
return this.validateStartGame(state, move.data.activePlayers, move.data.cards)
case 'CLEAR_MISMATCH':
return this.validateClearMismatch(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'RESUME_GAME':
return this.validateResumeGame(state)
case 'HOVER_CARD':
return this.validateHoverCard(state, move.data.cardId, move.playerId)
default:
return {
valid: false,
@@ -200,8 +183,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
private validateStartGame(
state: MemoryPairsState,
activePlayers: Player[],
cards?: GameCard[],
playerMetadata?: { [playerId: string]: any }
cards?: GameCard[]
): ValidationResult {
// Allow starting a new game from any phase (for "New Game" button)
@@ -221,7 +203,6 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
gameCards,
cards: gameCards,
activePlayers,
playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
gamePhase: 'playing',
gameStartTime: Date.now(),
currentPlayer: activePlayers[0],
@@ -230,15 +211,6 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
moves: 0,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
// PAUSE/RESUME: Save original config so we can detect changes
originalConfig: {
gameType: state.gameType,
difficulty: state.difficulty,
turnTimer: state.turnTimer,
},
// Clear any paused game state (starting fresh)
pausedGamePhase: undefined,
pausedGameState: undefined,
}
return {
@@ -248,16 +220,6 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
}
private validateClearMismatch(state: MemoryPairsState): ValidationResult {
// Only clear if there's actually a mismatch showing
// This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
// Nothing to clear - return current state unchanged
return {
valid: true,
newState: state,
}
}
// Clear mismatched cards and feedback
return {
valid: true,
@@ -270,243 +232,6 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
}
}
/**
* STANDARD ARCADE PATTERN: GO_TO_SETUP
*
* Transitions the game back to setup phase, allowing players to reconfigure
* the game. This is synchronized across all room members.
*
* Can be called from any phase (setup, playing, results).
*
* PAUSE/RESUME: If called from 'playing' or 'results', saves game state
* to allow resuming later (if config unchanged).
*
* Pattern for all arcade games:
* - Validates the move is allowed
* - Sets gamePhase to 'setup'
* - Preserves current configuration (gameType, difficulty, etc.)
* - Saves game state for resume if coming from active game
* - Resets game progression state (scores, cards, etc.)
*/
private validateGoToSetup(state: MemoryPairsState): ValidationResult {
// Determine if we're pausing an active game (for Resume functionality)
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
valid: true,
newState: {
...state,
gamePhase: 'setup',
// Pause/Resume: Save game state if pausing from active game
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
? {
gameCards: state.gameCards,
currentPlayer: state.currentPlayer,
matchedPairs: state.matchedPairs,
moves: state.moves,
scores: state.scores,
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
consecutiveMatches: state.consecutiveMatches,
gameStartTime: state.gameStartTime,
}
: undefined,
// Keep originalConfig if it exists (was set when game started)
// This allows detecting if config changed while paused
// Reset visible game progression
gameCards: [],
cards: [],
flippedCards: [],
currentPlayer: '',
matchedPairs: 0,
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
currentMoveStartTime: null,
celebrationAnimations: [],
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// Preserve configuration - players can modify in setup
// gameType, difficulty, turnTimer stay as-is
},
}
}
/**
* STANDARD ARCADE PATTERN: SET_CONFIG
*
* Updates a configuration field during setup phase. This is synchronized
* across all room members in real-time, allowing collaborative setup.
*
* Pattern for all arcade games:
* - Only allowed during setup phase
* - Validates field name and value
* - Updates the configuration field
* - Other room members see the change immediately (optimistic + server validation)
*
* @param state Current game state
* @param field Configuration field name
* @param value New value for the field
*/
private validateSetConfig(
state: MemoryPairsState,
field: 'gameType' | 'difficulty' | 'turnTimer',
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 'gameType':
if (value !== 'abacus-numeral' && value !== 'complement-pairs') {
return { valid: false, error: `Invalid gameType: ${value}` }
}
break
case 'difficulty':
if (![6, 8, 12, 15].includes(value)) {
return { valid: false, error: `Invalid difficulty: ${value}` }
}
break
case 'turnTimer':
if (typeof value !== 'number' || value < 5 || value > 300) {
return { valid: false, error: `Invalid turnTimer: ${value}` }
}
break
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
// PAUSE/RESUME: If there's a paused game and config is changing,
// clear the paused game state (can't resume anymore)
const clearPausedGame = !!state.pausedGamePhase
// Apply the configuration change
return {
valid: true,
newState: {
...state,
[field]: value,
// Update totalPairs if difficulty changes
...(field === 'difficulty' ? { totalPairs: value } : {}),
// Clear paused game if config changed
...(clearPausedGame
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
: {}),
},
}
}
/**
* STANDARD ARCADE PATTERN: RESUME_GAME
*
* Resumes a paused game if configuration hasn't changed.
* Restores the saved game state from when GO_TO_SETUP was called.
*
* Pattern for all arcade games:
* - Validates there's a paused game
* - Validates config hasn't changed since pause
* - Restores game state and phase
* - Clears paused game state
*/
private validateResumeGame(state: MemoryPairsState): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only resume from setup phase',
}
}
// Must have a paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return {
valid: false,
error: 'No paused game to resume',
}
}
// Config must match original (no changes while paused)
if (state.originalConfig) {
const configChanged =
state.gameType !== state.originalConfig.gameType ||
state.difficulty !== state.originalConfig.difficulty ||
state.turnTimer !== state.originalConfig.turnTimer
if (configChanged) {
return {
valid: false,
error: 'Cannot resume - configuration has changed',
}
}
}
// Restore the paused game
return {
valid: true,
newState: {
...state,
gamePhase: state.pausedGamePhase,
gameCards: state.pausedGameState.gameCards,
cards: state.pausedGameState.gameCards,
currentPlayer: state.pausedGameState.currentPlayer,
matchedPairs: state.pausedGameState.matchedPairs,
moves: state.pausedGameState.moves,
scores: state.pausedGameState.scores,
activePlayers: state.pausedGameState.activePlayers,
playerMetadata: state.pausedGameState.playerMetadata,
consecutiveMatches: state.pausedGameState.consecutiveMatches,
gameStartTime: state.pausedGameState.gameStartTime,
// Clear paused state
pausedGamePhase: undefined,
pausedGameState: undefined,
// Keep originalConfig for potential future pauses
},
}
}
/**
* Validate hover state update for networked presence
*
* Hover moves are lightweight and always valid - they just update
* which card a player is hovering over for UI feedback to other players.
*/
private validateHoverCard(
state: MemoryPairsState,
cardId: string | null,
playerId: string
): ValidationResult {
// Hover is always valid - it's just UI state for networked presence
// Update the player's hover state
return {
valid: true,
newState: {
...state,
playerHovers: {
...state.playerHovers,
[playerId]: cardId,
},
},
}
}
isGameComplete(state: MemoryPairsState): boolean {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
@@ -530,7 +255,6 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
moves: 0,
scores: {},
activePlayers: [],
playerMetadata: {}, // Initialize empty player metadata
consecutiveMatches: {},
gameStartTime: null,
gameEndTime: null,
@@ -540,12 +264,6 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
// PAUSE/RESUME: Initialize paused game fields
originalConfig: undefined,
pausedGamePhase: undefined,
pausedGameState: undefined,
// HOVER: Initialize hover state
playerHovers: {},
}
}
}

View File

@@ -1,37 +0,0 @@
"use strict";
/**
* Game validator registry
* Maps game names to their validators
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.matchingGameValidator = void 0;
exports.getValidator = getValidator;
const MatchingGameValidator_1 = require("./MatchingGameValidator");
const validators = new Map([
['matching', MatchingGameValidator_1.matchingGameValidator],
// Add other game validators here as they're implemented
]);
function getValidator(gameName) {
const validator = validators.get(gameName);
if (!validator) {
throw new Error(`No validator found for game: ${gameName}`);
}
return validator;
}
var MatchingGameValidator_2 = require("./MatchingGameValidator");
Object.defineProperty(exports, "matchingGameValidator", { enumerable: true, get: function () { return MatchingGameValidator_2.matchingGameValidator; } });
__exportStar(require("./types"), exports);

View File

@@ -1,6 +0,0 @@
"use strict";
/**
* Isomorphic game validation types
* Used on both client and server for arcade session validation
*/
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -33,7 +33,6 @@ export interface MatchingStartGameMove extends GameMove {
data: {
activePlayers: string[] // Player IDs (UUIDs)
cards?: any[] // GameCard type from context
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
}
}
@@ -42,40 +41,10 @@ export interface MatchingClearMismatchMove extends GameMove {
data: Record<string, never>
}
// Standard setup moves - pattern for all arcade games
export interface MatchingGoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface MatchingSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'gameType' | 'difficulty' | 'turnTimer'
value: any
}
}
export interface MatchingResumeGameMove extends GameMove {
type: 'RESUME_GAME'
data: Record<string, never>
}
export interface MatchingHoverCardMove extends GameMove {
type: 'HOVER_CARD'
data: {
cardId: string | null // null when mouse leaves card
}
}
export type MatchingGameMove =
| MatchingFlipCardMove
| MatchingStartGameMove
| MatchingClearMismatchMove
| MatchingGoToSetupMove
| MatchingSetConfigMove
| MatchingResumeGameMove
| MatchingHoverCardMove
// Generic game state union
export type GameState = MemoryPairsState // Add other game states as union later

View File

@@ -1,33 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"outDir": ".",
"rootDir": ".",
"noEmit": false,
"incremental": false,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"types": ["node", "react"]
},
"include": [
"src/db/index.ts",
"src/db/schema.ts",
"src/db/migrate.ts",
"src/lib/arcade/**/*.ts",
"src/app/games/matching/context/types.ts",
"src/app/games/matching/utils/cardGeneration.ts",
"src/app/games/matching/utils/matchValidation.ts",
"socket-server.ts"
],
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts"
]
}

View File

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

44
pnpm-lock.yaml generated
View File

@@ -240,9 +240,6 @@ importers:
storybook:
specifier: ^9.1.7
version: 9.1.10(@testing-library/dom@9.3.4)(prettier@3.6.2)(vite@5.4.20(@types/node@20.19.19)(terser@5.44.0))
tsc-alias:
specifier: ^1.8.16
version: 1.8.16
tsx:
specifier: ^4.20.5
version: 4.20.6
@@ -4682,10 +4679,6 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
common-path-prefix@3.0.0:
resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==}
@@ -6944,10 +6937,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mylas@2.1.13:
resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==}
engines: {node: '>=12.0.0'}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -7519,10 +7508,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
plimit-lit@1.6.1:
resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==}
engines: {node: '>=12'}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -7759,10 +7744,6 @@ packages:
resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==}
engines: {node: '>=0.4.x'}
queue-lit@1.5.2:
resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==}
engines: {node: '>=12'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -8721,11 +8702,6 @@ packages:
ts-pattern@5.0.5:
resolution: {integrity: sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA==}
tsc-alias@1.8.16:
resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==}
engines: {node: '>=16.20.2'}
hasBin: true
tsconfck@2.1.2:
resolution: {integrity: sha512-ghqN1b0puy3MhhviwO2kGF8SeMDNhEbnKxjK7h6+fvY9JAxqvXi8y5NAHSQv687OVboS2uZIByzGd45/YxrRHg==}
engines: {node: ^14.13.1 || ^16 || >=18}
@@ -14295,8 +14271,6 @@ snapshots:
commander@8.3.0: {}
commander@9.5.0: {}
common-path-prefix@3.0.0: {}
commondir@1.0.1: {}
@@ -16860,8 +16834,6 @@ snapshots:
ms@2.1.3: {}
mylas@2.1.13: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@@ -17362,10 +17334,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
plimit-lit@1.6.1:
dependencies:
queue-lit: 1.5.2
pluralize@8.0.0: {}
polished@4.3.1:
@@ -17615,8 +17583,6 @@ snapshots:
querystring-es3@0.2.1: {}
queue-lit@1.5.2: {}
queue-microtask@1.2.3: {}
ramda@0.29.0: {}
@@ -18732,16 +18698,6 @@ snapshots:
ts-pattern@5.0.5: {}
tsc-alias@1.8.16:
dependencies:
chokidar: 3.6.0
commander: 9.5.0
get-tsconfig: 4.11.0
globby: 11.1.0
mylas: 2.1.13
normalize-path: 3.0.0
plimit-lit: 1.6.1
tsconfck@2.1.2(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3