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:
Thomas Hallock
2025-10-09 08:40:13 -05:00
parent 3a01f4637d
commit 2856f4b83f
4 changed files with 89 additions and 13 deletions

View File

@@ -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 = {

View File

@@ -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(() => {

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) => 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)

View File

@@ -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 {