fix: implement shared session architecture for room-based multiplayer
Fixes state divergence issue where different room members saw different game states (e.g., version 11 vs version 2). ## Problem - Each user created their own session (one row per userId in arcadeSessions) - When users made moves, they updated different database rows - Broadcasting tried to sync but versions diverged - Result: Complete state inconsistency between room members ## Solution Implement shared session architecture where all room members access the same session: ### Backend Changes (session-manager.ts) - Add getArcadeSessionByRoom(): Look up session by roomId - Modify createArcadeSession(): Check for existing room session first - Modify applyGameMove(): Accept optional roomId parameter for room-based lookup ### Server Changes (socket-server.ts) - Update game-move handler to accept roomId in payload - Pass roomId to applyGameMove() for shared session access - Update join-arcade-session to use room-based lookup when roomId provided ### Client Changes - Update useArcadeSocket.sendMove() to accept and send roomId - Update useArcadeSession.sendMove() to pass roomId to socket - Fix sendMove interface type (playerId must be included, not omitted) ## Result All room members now read/write to single shared session with consistent version numbers. State stays synchronized across all clients. ## Note Codebase has pre-existing TypeScript errors in unrelated files (abacus-react imports, tutorial components) that are not addressed by this fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
createArcadeSession,
|
||||
deleteArcadeSession,
|
||||
getArcadeSession,
|
||||
getArcadeSessionByRoom,
|
||||
updateSessionActivity,
|
||||
} from './src/lib/arcade/session-manager'
|
||||
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
|
||||
@@ -56,9 +57,19 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
|
||||
// Send current session state if exists
|
||||
// For room-based games, look up shared room session
|
||||
try {
|
||||
const session = await getArcadeSession(userId)
|
||||
const session = roomId
|
||||
? await getArcadeSessionByRoom(roomId)
|
||||
: 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,
|
||||
@@ -67,6 +78,10 @@ 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) {
|
||||
@@ -77,19 +92,23 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
)
|
||||
|
||||
// Handle game moves
|
||||
socket.on('game-move', async (data: { userId: string; move: GameMove }) => {
|
||||
socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => {
|
||||
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') {
|
||||
const existingSession = await getArcadeSession(data.userId)
|
||||
// For room-based games, check if room session exists
|
||||
const existingSession = data.roomId
|
||||
? await getArcadeSessionByRoom(data.roomId)
|
||||
: await getArcadeSession(data.userId)
|
||||
|
||||
if (!existingSession) {
|
||||
console.log('🎯 Creating new session for START_GAME')
|
||||
@@ -174,7 +193,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
// Apply game move - use roomId for room-based games to access shared session
|
||||
const result = await applyGameMove(data.userId, data.move, data.roomId)
|
||||
|
||||
if (result.success && result.session) {
|
||||
const moveAcceptedData = {
|
||||
|
||||
@@ -48,8 +48,9 @@ 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, 'playerId' | 'timestamp'>) => void
|
||||
sendMove: (move: Omit<GameMove, 'timestamp'>) => void
|
||||
|
||||
/**
|
||||
* Exit the arcade session
|
||||
@@ -149,10 +150,10 @@ export function useArcadeSession<TState>(
|
||||
// Apply optimistically
|
||||
optimistic.applyOptimisticMove(fullMove)
|
||||
|
||||
// Send to server
|
||||
socketSendMove(userId, fullMove)
|
||||
// Send to server with roomId for room-based games
|
||||
socketSendMove(userId, fullMove, roomId)
|
||||
},
|
||||
[userId, optimistic, socketSendMove]
|
||||
[userId, roomId, optimistic, socketSendMove]
|
||||
)
|
||||
|
||||
const exitSession = useCallback(() => {
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface UseArcadeSocketReturn {
|
||||
socket: Socket | null
|
||||
connected: boolean
|
||||
joinSession: (userId: string, roomId?: string) => void
|
||||
sendMove: (userId: string, move: GameMove) => void
|
||||
sendMove: (userId: string, move: GameMove, roomId?: string) => 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) => {
|
||||
(userId: string, move: GameMove, roomId?: string) => {
|
||||
if (!socket) {
|
||||
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
|
||||
return
|
||||
}
|
||||
const payload = { userId, move }
|
||||
const payload = { userId, move, roomId }
|
||||
console.log(
|
||||
'[ArcadeSocket] Sending game-move event with payload:',
|
||||
JSON.stringify(payload, null, 2)
|
||||
|
||||
@@ -37,8 +37,35 @@ 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
|
||||
@@ -46,6 +73,19 @@ 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),
|
||||
@@ -80,6 +120,12 @@ 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
|
||||
}
|
||||
@@ -130,9 +176,18 @@ 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): Promise<SessionUpdateResult> {
|
||||
const session = await getArcadeSession(userId)
|
||||
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)
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user