Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cb175982a | ||
|
|
ee6094d59d | ||
|
|
9d0c488f2b | ||
|
|
e7d2a73ddf |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,3 +1,17 @@
|
||||
## [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.6.0...v2.7.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* extend GameModeContext to support room-based multiplayer ([ee6094d](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6094d59d26a9e80ba5d023ca6dc13143bea308))
|
||||
|
||||
## [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.5.0...v2.6.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* refactor room addressing to /arcade/room ([e7d2a73](https://github.com/antialias/soroban-abacus-flashcards/commit/e7d2a73ddf2048691325a18e3d71a7ece444c131))
|
||||
|
||||
## [2.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.6...v2.5.0) (2025-10-08)
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"Bash(npm run pre-commit:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git stash:*)"
|
||||
"Bash(git stash:*)",
|
||||
"Bash(members of the room\" requirement for room-based gameplay.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
52
apps/web/src/app/api/arcade/rooms/current/route.ts
Normal file
52
apps/web/src/app/api/arcade/rooms/current/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getUserRooms } from '@/lib/arcade/room-membership'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/current
|
||||
* Returns the user's current room (if any)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const userId = await getViewerId()
|
||||
|
||||
// Get all rooms user is in (should be at most 1 due to modal room enforcement)
|
||||
const roomIds = await getUserRooms(userId)
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
return NextResponse.json({ room: null }, { status: 200 })
|
||||
}
|
||||
|
||||
const roomId = roomIds[0]
|
||||
|
||||
// Get room data
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get members
|
||||
const members = await getRoomMembers(roomId)
|
||||
|
||||
// Get active players for all members
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Current Room API] Error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch current room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
77
apps/web/src/app/arcade/room/page.tsx
Normal file
77
apps/web/src/app/arcade/room/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
|
||||
// Redirect to arcade if no room
|
||||
useEffect(() => {
|
||||
if (!isLoading && !roomData) {
|
||||
console.log('[RoomPage] No active room, redirecting to /arcade')
|
||||
router.push('/arcade')
|
||||
}
|
||||
}, [isLoading, roomData, router])
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show nothing while redirecting
|
||||
if (!roomData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Render the appropriate game based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { Player as DBPlayer } from '@/db/schema/players'
|
||||
import {
|
||||
useCreatePlayer,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
useUpdatePlayer,
|
||||
useUserPlayers,
|
||||
} from '@/hooks/useUserPlayers'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getNextPlayerColor } from '../types/player'
|
||||
|
||||
// Client-side Player type (compatible with old type)
|
||||
@@ -66,28 +69,72 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const { mutate: createPlayer } = useCreatePlayer()
|
||||
const { mutate: updatePlayerMutation } = useUpdatePlayer()
|
||||
const { mutate: deletePlayer } = useDeletePlayer()
|
||||
const { roomData } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Convert DB players to Map
|
||||
const players = useMemo(() => {
|
||||
// Convert DB players to Map (local players)
|
||||
const localPlayers = useMemo(() => {
|
||||
const map = new Map<string, Player>()
|
||||
dbPlayers.forEach((dbPlayer) => {
|
||||
map.set(dbPlayer.id, toClientPlayer(dbPlayer))
|
||||
map.set(dbPlayer.id, {
|
||||
...toClientPlayer(dbPlayer),
|
||||
isLocal: true,
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [dbPlayers])
|
||||
|
||||
// Track active players from DB isActive status
|
||||
// When in a room, merge all players from all room members
|
||||
const players = useMemo(() => {
|
||||
const map = new Map<string, Player>(localPlayers)
|
||||
|
||||
if (roomData) {
|
||||
// Add players from other room members (marked as remote)
|
||||
Object.entries(roomData.memberPlayers).forEach(([userId, memberPlayers]) => {
|
||||
// Skip the current user's players (already in localPlayers)
|
||||
if (userId === viewerId) return
|
||||
|
||||
memberPlayers.forEach((roomPlayer) => {
|
||||
map.set(roomPlayer.id, {
|
||||
id: roomPlayer.id,
|
||||
name: roomPlayer.name,
|
||||
emoji: roomPlayer.emoji,
|
||||
color: roomPlayer.color,
|
||||
createdAt: Date.now(),
|
||||
isActive: true, // Players in memberPlayers are active
|
||||
isLocal: false, // Remote player
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}, [localPlayers, roomData, viewerId])
|
||||
|
||||
// Track active players (local + room members when in a room)
|
||||
const activePlayers = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
dbPlayers.forEach((player) => {
|
||||
if (player.isActive) {
|
||||
set.add(player.id)
|
||||
}
|
||||
})
|
||||
|
||||
if (roomData) {
|
||||
// In room mode: all players from all members are active
|
||||
Object.values(roomData.memberPlayers).forEach((memberPlayers) => {
|
||||
memberPlayers.forEach((player) => {
|
||||
set.add(player.id)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Solo mode: only local active players
|
||||
dbPlayers.forEach((player) => {
|
||||
if (player.isActive) {
|
||||
set.add(player.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return set
|
||||
}, [dbPlayers])
|
||||
}, [dbPlayers, roomData])
|
||||
|
||||
// Initialize with default players if none exist
|
||||
useEffect(() => {
|
||||
@@ -111,6 +158,26 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [dbPlayers, isLoading, isInitialized, createPlayer])
|
||||
|
||||
// When in a room, broadcast player updates to other members
|
||||
useEffect(() => {
|
||||
if (!roomData || !viewerId || !isInitialized) return
|
||||
|
||||
const socket = io({ path: '/api/socket' })
|
||||
|
||||
// Wait for connection before emitting
|
||||
socket.on('connect', () => {
|
||||
console.log('[GameModeContext] Emitting players-updated for room:', roomData.id)
|
||||
socket.emit('players-updated', {
|
||||
roomId: roomData.id,
|
||||
userId: viewerId,
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [dbPlayers, roomData, viewerId, isInitialized])
|
||||
|
||||
const addPlayer = (playerData?: Partial<Player>) => {
|
||||
const playerList = Array.from(players.values())
|
||||
|
||||
@@ -125,15 +192,33 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const updatePlayer = (id: string, updates: Partial<Player>) => {
|
||||
updatePlayerMutation({ id, updates })
|
||||
const player = players.get(id)
|
||||
// Only allow updating local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation({ id, updates })
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot update remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const removePlayer = (id: string) => {
|
||||
deletePlayer(id)
|
||||
const player = players.get(id)
|
||||
// Only allow removing local players
|
||||
if (player?.isLocal) {
|
||||
deletePlayer(id)
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot remove remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const setActive = (id: string, active: boolean) => {
|
||||
updatePlayerMutation({ id, updates: { isActive: active } })
|
||||
const player = players.get(id)
|
||||
// Only allow changing active status of local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation({ id, updates: { isActive: active } })
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot change active status of remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const getActivePlayers = (): Player[] => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useViewerId } from './useViewerId'
|
||||
|
||||
export interface RoomMember {
|
||||
id: string
|
||||
@@ -27,21 +27,55 @@ export interface RoomData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and subscribe to room data when on a room page
|
||||
* Returns null if not on a room page
|
||||
* Hook to fetch and subscribe to the user's current room data
|
||||
* Returns null if user is not in any room
|
||||
*/
|
||||
export function useRoomData() {
|
||||
const pathname = usePathname()
|
||||
const { data: userId } = useViewerId()
|
||||
const [socket, setSocket] = useState<Socket | null>(null)
|
||||
const [roomData, setRoomData] = useState<RoomData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Extract roomId from pathname like /arcade/rooms/[roomId]/...
|
||||
const roomId = pathname?.match(/\/arcade\/rooms\/([^/]+)/)?.[1]
|
||||
|
||||
// Initialize socket connection when on a room page
|
||||
// Fetch the user's current room
|
||||
useEffect(() => {
|
||||
if (!roomId) {
|
||||
if (!userId) {
|
||||
setRoomData(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
// Fetch current room data
|
||||
fetch('/api/arcade/rooms/current')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch current room')
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.room) {
|
||||
setRoomData({
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
})
|
||||
} else {
|
||||
setRoomData(null)
|
||||
}
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch room data:', error)
|
||||
setRoomData(null)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [userId])
|
||||
|
||||
// Initialize socket connection when user has a room
|
||||
useEffect(() => {
|
||||
if (!roomData?.id || !userId) {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
setSocket(null)
|
||||
@@ -50,47 +84,49 @@ export function useRoomData() {
|
||||
}
|
||||
|
||||
const sock = io({ path: '/api/socket' })
|
||||
|
||||
sock.on('connect', () => {
|
||||
console.log('[useRoomData] Socket connected, joining room:', roomData.id)
|
||||
// Join the room to receive updates
|
||||
sock.emit('join-room', { roomId: roomData.id, userId })
|
||||
})
|
||||
|
||||
sock.on('disconnect', () => {
|
||||
console.log('[useRoomData] Socket disconnected')
|
||||
})
|
||||
|
||||
setSocket(sock)
|
||||
|
||||
return () => {
|
||||
sock.disconnect()
|
||||
if (sock.connected) {
|
||||
// Leave the room before disconnecting
|
||||
sock.emit('leave-room', { roomId: roomData.id, userId })
|
||||
sock.disconnect()
|
||||
}
|
||||
}
|
||||
}, [roomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) {
|
||||
setRoomData(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
// Fetch initial room data
|
||||
fetch(`/api/arcade/rooms/${roomId}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch room')
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
setRoomData({
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
})
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch room data:', error)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [roomId])
|
||||
}, [roomData?.id, userId])
|
||||
|
||||
// Subscribe to real-time updates via socket
|
||||
useEffect(() => {
|
||||
if (!socket || !roomId) return
|
||||
if (!socket || !roomData?.id) return
|
||||
|
||||
const handleRoomJoined = (data: {
|
||||
roomId: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
console.log('[useRoomData] Received room-joined event:', data)
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
members: data.members,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemberJoined = (data: {
|
||||
roomId: string
|
||||
@@ -98,7 +134,8 @@ export function useRoomData() {
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomId) {
|
||||
console.log('[useRoomData] Received member-joined event:', data)
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
@@ -116,7 +153,8 @@ export function useRoomData() {
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomId) {
|
||||
console.log('[useRoomData] Received member-left event:', data)
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
@@ -132,7 +170,8 @@ export function useRoomData() {
|
||||
roomId: string
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomId) {
|
||||
console.log('[useRoomData] Received room-players-updated event:', data)
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
@@ -143,20 +182,22 @@ export function useRoomData() {
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
socket.on('room-players-updated', handleRoomPlayersUpdated)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
socket.off('member-joined', handleMemberJoined)
|
||||
socket.off('member-left', handleMemberLeft)
|
||||
socket.off('room-players-updated', handleRoomPlayersUpdated)
|
||||
}
|
||||
}, [socket, roomId])
|
||||
}, [socket, roomData?.id])
|
||||
|
||||
return {
|
||||
roomData,
|
||||
isLoading,
|
||||
isInRoom: !!roomId,
|
||||
isInRoom: !!roomData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.5.0",
|
||||
"version": "2.7.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user