fix: create arcade sessions on room join to enable config changes

Fixes "No active session found" error when adjusting game settings
before starting a game in arcade rooms.

**Problem:**
- Sessions were only created on START_GAME move
- SET_CONFIG moves require an active session in setup phase
- Users couldn't adjust settings until after starting game

**Solution:**
- Create session in setup phase when user joins room (if none exists)
- Initialize with room's game config from database
- Allows SET_CONFIG moves before game starts

**Changes:**
- socket-server.ts:72-100 - Auto-create session on join-arcade-session
- RoomMemoryPairsProvider.tsx:4 - Remove unused import
- nas-deployment/docker-compose.yaml:15 - Fix DB volume mount path

**Related:**
- Also fixes database persistence by correcting volume mount from
  ./data:/app/data to ./data:/app/apps/web/data

🤖 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-10 13:51:11 -05:00
parent 9ba00deaaa
commit c29501f666
3 changed files with 469 additions and 550 deletions

View File

@@ -1,35 +1,29 @@
"use client";
'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 ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
buildPlayerOwnershipFromRoomData,
} from "@/lib/arcade/player-ownership.client";
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";
} from '@/lib/arcade/player-ownership.client'
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",
gameType: 'abacus-numeral',
difficulty: 6,
turnTimer: 30,
gamePhase: "setup",
currentPlayer: "", // Will be set to first player ID on START_GAME
gamePhase: 'setup',
currentPlayer: '', // Will be set to first player ID on START_GAME
matchedPairs: 0,
totalPairs: 6,
moves: 0,
@@ -51,38 +45,32 @@ const initialState: MemoryPairsState = {
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 {
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
switch (move.type) {
case "START_GAME":
case 'START_GAME':
// Generate cards and initialize game
return {
...state,
gamePhase: "playing",
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 }),
{},
),
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] || "",
currentPlayer: move.data.activePlayers[0] || '',
gameStartTime: Date.now(),
gameEndTime: null,
currentMoveStartTime: Date.now(),
@@ -98,33 +86,31 @@ function applyMoveOptimistically(
},
pausedGamePhase: undefined,
pausedGameState: undefined,
};
}
case "FLIP_CARD": {
case 'FLIP_CARD': {
// Optimistically flip the card
const card = state.gameCards.find((c) => c.id === move.data.cardId);
if (!card) return state;
const card = state.gameCards.find((c) => c.id === move.data.cardId)
if (!card) return state
const newFlippedCards = [...state.flippedCards, card];
const newFlippedCards = [...state.flippedCards, card]
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime:
state.flippedCards.length === 0
? Date.now()
: state.currentMoveStartTime,
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
showMismatchFeedback: false,
};
}
}
case "CLEAR_MISMATCH": {
case 'CLEAR_MISMATCH': {
// Clear hover for all non-current players
const clearedHovers = { ...state.playerHovers };
const clearedHovers = { ...state.playerHovers }
for (const playerId of state.activePlayers) {
if (playerId !== state.currentPlayer) {
clearedHovers[playerId] = null;
clearedHovers[playerId] = null
}
}
@@ -136,17 +122,16 @@ function applyMoveOptimistically(
isProcessingMove: false,
// Clear hovers for non-current players
playerHovers: clearedHovers,
};
}
}
case "GO_TO_SETUP": {
case 'GO_TO_SETUP': {
// Return to setup phase - pause game if coming from playing/results
const isPausingGame =
state.gamePhase === "playing" || state.gamePhase === "results";
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
return {
...state,
gamePhase: "setup",
gamePhase: 'setup',
// PAUSE: Save game state if pausing from active game
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
pausedGameState: isPausingGame
@@ -166,7 +151,7 @@ function applyMoveOptimistically(
gameCards: [],
cards: [],
flippedCards: [],
currentPlayer: "",
currentPlayer: '',
matchedPairs: 0,
moves: 0,
scores: {},
@@ -180,19 +165,19 @@ function applyMoveOptimistically(
isProcessingMove: false,
showMismatchFeedback: false,
lastMatchedPair: null,
};
}
}
case "SET_CONFIG": {
case 'SET_CONFIG': {
// Update configuration field optimistically
const { field, value } = move.data as { field: string; value: any };
const clearPausedGame = !!state.pausedGamePhase;
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 } : {}),
...(field === 'difficulty' ? { totalPairs: value } : {}),
// Clear paused game if config changed
...(clearPausedGame
? {
@@ -201,13 +186,13 @@ function applyMoveOptimistically(
originalConfig: undefined,
}
: {}),
};
}
}
case "RESUME_GAME": {
case 'RESUME_GAME': {
// Resume paused game
if (!state.pausedGamePhase || !state.pausedGameState) {
return state; // No paused game, no-op
return state // No paused game, no-op
}
return {
@@ -226,10 +211,10 @@ function applyMoveOptimistically(
// Clear paused state
pausedGamePhase: undefined,
pausedGameState: undefined,
};
}
}
case "HOVER_CARD": {
case 'HOVER_CARD': {
// Update player hover state for networked presence
return {
...state,
@@ -237,11 +222,11 @@ function applyMoveOptimistically(
...state.playerHovers,
[move.playerId]: move.data.cardId,
},
};
}
}
default:
return state;
return state
}
}
@@ -249,19 +234,15 @@ function applyMoveOptimistically(
// 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();
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);
const activePlayers = Array.from(activePlayerIds)
// Derive game mode from active player count
const gameMode = activePlayerCount > 1 ? "multiplayer" : "single";
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
@@ -273,11 +254,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
connected: _connected,
exitSession,
} = useArcadeSession<MemoryPairsState>({
userId: viewerId || "",
userId: viewerId || '',
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
initialState,
applyMove: applyMoveOptimistically,
});
})
// Handle mismatch feedback timeout
useEffect(() => {
@@ -286,93 +267,80 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Server will validate that cards are still in mismatch state before clearing
const timeout = setTimeout(() => {
sendMove({
type: "CLEAR_MISMATCH",
type: 'CLEAR_MISMATCH',
playerId: state.currentPlayer,
data: {},
});
}, 1500);
})
}, 1500)
return () => clearTimeout(timeout);
return () => clearTimeout(timeout)
}
}, [
state.showMismatchFeedback,
state.flippedCards.length,
sendMove,
state.currentPlayer,
]);
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
// Computed values
const isGameActive = state.gamePhase === "playing";
const isGameActive = state.gamePhase === 'playing'
const canFlipCard = useCallback(
(cardId: string): boolean => {
console.log("[RoomProvider][canFlipCard] Checking card:", {
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;
console.log('[RoomProvider][canFlipCard] Blocked: game not active or processing')
return false
}
const card = state.gameCards.find((c) => c.id === cardId);
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;
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;
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;
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:", {
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;
'[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",
);
'[RoomProvider][canFlipCard] WARNING: Current player not found in players map, allowing move'
)
}
}
console.log("[RoomProvider][canFlipCard] ALLOWED: All checks passed");
return true;
console.log('[RoomProvider][canFlipCard] ALLOWED: All checks passed')
return true
},
[
isGameActive,
@@ -382,263 +350,218 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
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,
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
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
: 0,
}),
[
state.moves,
state.matchedPairs,
state.totalPairs,
state.gameStartTime,
state.gameEndTime,
],
);
[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;
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]);
)
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
const canResumeGame = useMemo(() => {
return (
!!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
);
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged]);
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Helper to build player metadata with correct userId ownership
// Uses centralized ownership utilities
const buildPlayerMetadata = useCallback(
(playerIds: string[]) => {
// Build ownership map from room data
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData);
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
// Use centralized utility to build metadata
return buildPlayerMetadataUtil(
playerIds,
playerOwnership,
players,
viewerId,
);
return buildPlayerMetadataUtil(playerIds, playerOwnership, players, viewerId)
},
[players, roomData, viewerId],
);
[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;
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);
const playerMetadata = buildPlayerMetadata(activePlayers)
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty);
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0];
const firstPlayer = activePlayers[0]
sendMove({
type: "START_GAME",
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
});
}, [
state.gameType,
state.difficulty,
activePlayers,
buildPlayerMetadata,
sendMove,
]);
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
const flipCard = useCallback(
(cardId: string) => {
console.log("[RoomProvider] flipCard called:", {
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;
console.log('[RoomProvider] Cannot flip card - canFlipCard returned false')
return
}
const move = {
type: "FLIP_CARD" as const,
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);
}
console.log('[RoomProvider] Sending FLIP_CARD move via sendMove:', move)
sendMove(move)
},
[
canFlipCard,
sendMove,
viewerId,
state.currentPlayer,
state.activePlayers,
state.gamePhase,
],
);
[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;
console.error('[RoomMemoryPairs] Cannot reset game without active players')
return
}
// Capture player metadata with correct userId ownership
const playerMetadata = buildPlayerMetadata(activePlayers);
const playerMetadata = buildPlayerMetadata(activePlayers)
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty);
const cards = generateGameCards(state.gameType, state.difficulty)
// Use first active player as playerId for START_GAME move
const firstPlayer = activePlayers[0];
const firstPlayer = activePlayers[0]
sendMove({
type: "START_GAME",
type: 'START_GAME',
playerId: firstPlayer,
data: {
cards,
activePlayers,
playerMetadata,
},
});
}, [
state.gameType,
state.difficulty,
activePlayers,
buildPlayerMetadata,
sendMove,
]);
})
}, [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] || "";
const playerId = activePlayers[0] || ''
sendMove({
type: "SET_CONFIG",
type: 'SET_CONFIG',
playerId,
data: { field: "gameType", value: gameType },
});
data: { field: 'gameType', value: gameType },
})
},
[activePlayers, sendMove],
);
[activePlayers, sendMove]
)
const setDifficulty = useCallback(
(difficulty: typeof state.difficulty) => {
const playerId = activePlayers[0] || "";
const playerId = activePlayers[0] || ''
sendMove({
type: "SET_CONFIG",
type: 'SET_CONFIG',
playerId,
data: { field: "difficulty", value: difficulty },
});
data: { field: 'difficulty', value: difficulty },
})
},
[activePlayers, sendMove],
);
[activePlayers, sendMove]
)
const setTurnTimer = useCallback(
(turnTimer: typeof state.turnTimer) => {
const playerId = activePlayers[0] || "";
const playerId = activePlayers[0] || ''
sendMove({
type: "SET_CONFIG",
type: 'SET_CONFIG',
playerId,
data: { field: "turnTimer", value: turnTimer },
});
data: { field: 'turnTimer', value: turnTimer },
})
},
[activePlayers, sendMove],
);
[activePlayers, sendMove]
)
const goToSetup = useCallback(() => {
// Send GO_TO_SETUP move - synchronized across all room members
const playerId = activePlayers[0] || state.currentPlayer || "";
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: "GO_TO_SETUP",
type: 'GO_TO_SETUP',
playerId,
data: {},
});
}, [activePlayers, state.currentPlayer, sendMove]);
})
}, [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;
console.warn('[RoomMemoryPairs] Cannot resume - no paused game or config changed')
return
}
const playerId = activePlayers[0] || state.currentPlayer || "";
const playerId = activePlayers[0] || state.currentPlayer || ''
sendMove({
type: "RESUME_GAME",
type: 'RESUME_GAME',
playerId,
data: {},
});
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove]);
})
}, [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
const playerId = state.currentPlayer || activePlayers[0] || ''
if (!playerId) return // No active player to send hover for
sendMove({
type: "HOVER_CARD",
type: 'HOVER_CARD',
playerId,
data: { cardId },
});
})
},
[state.currentPlayer, activePlayers, sendMove],
);
[state.currentPlayer, activePlayers, sendMove]
)
// NO MORE effectiveState merging! Just use session state directly with gameMode added
const effectiveState = { ...state, gameMode } as MemoryPairsState & {
gameMode: GameMode;
};
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",
);
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
},
isGameActive,
canFlipCard,
@@ -658,14 +581,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
exitSession,
gameMode,
activePlayers,
};
}
return (
<MemoryPairsContext.Provider value={contextValue}>
{children}
</MemoryPairsContext.Provider>
);
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
}
// Export the hook for this provider
export { useMemoryPairs } from "./MemoryPairsContext";
export { useMemoryPairs } from './MemoryPairsContext'

View File

@@ -1,6 +1,6 @@
import type { Server as HTTPServer } from "http";
import { Server as SocketIOServer } from "socket.io";
import type { Server as SocketIOServerType } from "socket.io";
import type { Server as HTTPServer } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Server as SocketIOServerType } from 'socket.io'
import {
applyGameMove,
createArcadeSession,
@@ -8,21 +8,17 @@ import {
getArcadeSession,
getArcadeSessionByRoom,
updateSessionActivity,
} from "./lib/arcade/session-manager";
import { createRoom, getRoomById } from "./lib/arcade/room-manager";
import {
getRoomMembers,
getUserRooms,
setMemberOnline,
} from "./lib/arcade/room-membership";
import { getRoomActivePlayers } from "./lib/arcade/player-manager";
import type { GameMove, GameName } from "./lib/arcade/validation";
import { matchingGameValidator } from "./lib/arcade/validation/MatchingGameValidator";
} from './lib/arcade/session-manager'
import { createRoom, getRoomById } from './lib/arcade/room-manager'
import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership'
import { getRoomActivePlayers } from './lib/arcade/player-manager'
import type { GameMove, GameName } from './lib/arcade/validation'
import { matchingGameValidator } from './lib/arcade/validation/MatchingGameValidator'
// Use globalThis to store socket.io instance to avoid module isolation issues
// This ensures the same instance is accessible across dynamic imports
declare global {
var __socketIO: SocketIOServerType | undefined;
var __socketIO: SocketIOServerType | undefined
}
/**
@@ -30,378 +26,382 @@ declare global {
* Returns null if not initialized
*/
export function getSocketIO(): SocketIOServerType | null {
return globalThis.__socketIO || null;
return globalThis.__socketIO || null
}
export function initializeSocketServer(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
path: "/api/socket",
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || "http://localhost:3000",
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
credentials: true,
},
});
})
io.on("connection", (socket) => {
console.log("🔌 Client connected:", socket.id);
let currentUserId: string | null = null;
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id)
let currentUserId: string | null = null
// Join arcade session room
socket.on(
"join-arcade-session",
'join-arcade-session',
async ({ userId, roomId }: { userId: string; roomId?: string }) => {
currentUserId = userId;
socket.join(`arcade:${userId}`);
console.log(`👤 User ${userId} joined arcade room`);
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}`);
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
let session = roomId
? await getArcadeSessionByRoom(roomId)
: await getArcadeSession(userId);
: await getArcadeSession(userId)
// If no session exists for this room, create one in setup phase
// This allows users to send SET_CONFIG moves before starting the game
if (!session && roomId) {
console.log('[join-arcade-session] Creating initial session for room:', roomId)
// Get the room to determine game type and config
const room = await getRoomById(roomId)
if (room) {
// Get initial state from validator (starts in "setup" phase)
const initialState = matchingGameValidator.getInitialState({
difficulty: (room.gameConfig as any)?.difficulty || 6,
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
})
session = await createArcadeSession({
userId,
gameName: room.gameName as GameName,
gameUrl: '/arcade/room',
initialState,
activePlayers: [], // No active players yet (setup phase)
roomId: room.id,
})
console.log('[join-arcade-session] Created initial session:', {
roomId,
sessionId: session.userId,
gamePhase: (session.gameState as any).gamePhase,
})
}
}
if (session) {
console.log("[join-arcade-session] Found session:", {
console.log('[join-arcade-session] Found session:', {
userId,
roomId,
version: session.version,
sessionUserId: session.userId,
});
socket.emit("session-state", {
})
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:", {
console.log('[join-arcade-session] No active session found for:', {
userId,
roomId,
});
socket.emit("no-active-session");
})
socket.emit('no-active-session')
}
} catch (error) {
console.error("Error fetching session:", error);
socket.emit("session-error", { error: "Failed to fetch session" });
console.error('Error fetching session:', error)
socket.emit('session-error', { error: 'Failed to fetch session' })
}
},
);
}
)
// Handle game moves
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),
});
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") {
// For room-based games, check if room session exists
const existingSession = data.roomId
? await getArcadeSessionByRoom(data.roomId)
: await getArcadeSession(data.userId);
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)
if (!existingSession) {
console.log("🎯 Creating new session for START_GAME");
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 as any)?.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.getInitialState({
difficulty: 6,
gameType: "abacus-numeral",
turnTimer: 30,
});
// Check if user is already in a room for this game
const userRoomIds = await getUserRooms(data.userId);
let room = null;
// Look for an existing active room for this game
for (const roomId of userRoomIds) {
const existingRoom = await 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 createRoom({
name: "Auto-generated Room",
createdBy: data.userId,
creatorName: "Player",
gameName: "matching" as GameName,
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 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 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 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}`,
);
// activePlayers must be provided in the START_GAME move data
const activePlayers = (data.move.data as any)?.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
}
// Update activity timestamp
await updateSessionActivity(data.userId);
} else {
// Send rejection only to the requesting socket
socket.emit("move-rejected", {
error: result.error,
move: data.move,
versionConflict: result.versionConflict,
});
// Get initial state from validator
const initialState = matchingGameValidator.getInitialState({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
})
// Check if user is already in a room for this game
const userRoomIds = await getUserRooms(data.userId)
let room = null
// Look for an existing active room for this game
for (const roomId of userRoomIds) {
const existingRoom = await 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 createRoom({
name: 'Auto-generated Room',
createdBy: data.userId,
creatorName: 'Player',
gameName: 'matching' as GameName,
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 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 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')
}
}
} catch (error) {
console.error("Error processing move:", error);
socket.emit("move-rejected", {
error: "Server error processing move",
move: 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 = {
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 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 }: { userId: string }) => {
console.log("🚪 User exiting arcade session:", userId);
socket.on('exit-arcade-session', async ({ userId }: { userId: string }) => {
console.log('🚪 User exiting arcade session:', userId)
try {
await deleteArcadeSession(userId);
io!.to(`arcade:${userId}`).emit("session-ended");
await 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" });
console.error('Error ending session:', error)
socket.emit('session-error', { error: 'Failed to end session' })
}
});
})
// Keep-alive ping
socket.on("ping-session", async ({ userId }: { userId: string }) => {
socket.on('ping-session', async ({ userId }: { userId: string }) => {
try {
await updateSessionActivity(userId);
socket.emit("pong-session");
await updateSessionActivity(userId)
socket.emit('pong-session')
} catch (error) {
console.error("Error updating activity:", error);
console.error('Error updating activity:', error)
}
});
})
// Room: Join
socket.on(
"join-room",
async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🏠 User ${userId} joining room ${roomId}`);
socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🏠 User ${userId} joining room ${roomId}`)
try {
// Join the socket room
socket.join(`room:${roomId}`);
try {
// Join the socket room
socket.join(`room:${roomId}`)
// Mark member as online
await setMemberOnline(roomId, userId, true);
// Mark member as online
await setMemberOnline(roomId, userId, true)
// Get room data
const members = await getRoomMembers(roomId);
const memberPlayers = await getRoomActivePlayers(roomId);
// Get room data
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {};
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" });
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
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 }: { roomId: string; userId: string }) => {
console.log(`🚪 User ${userId} leaving room ${roomId}`);
socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🚪 User ${userId} leaving room ${roomId}`)
try {
// Leave the socket room
socket.leave(`room:${roomId}`);
try {
// Leave the socket room
socket.leave(`room:${roomId}`)
// Mark member as offline
await setMemberOnline(roomId, userId, false);
// Mark member as offline
await setMemberOnline(roomId, userId, false)
// Get updated members
const members = await getRoomMembers(roomId);
const memberPlayers = await getRoomActivePlayers(roomId);
// Get updated members
const members = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object
const memberPlayersObj: Record<string, any[]> = {};
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);
// Convert memberPlayers Map to object
const memberPlayersObj: Record<string, any[]> = {}
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 }: { roomId: string; userId: string }) => {
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`);
socket.on('players-updated', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`)
try {
// Get updated player data
const memberPlayers = await getRoomActivePlayers(roomId);
try {
// Get updated player data
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object
const memberPlayersObj: Record<string, any[]> = {};
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" });
// Convert memberPlayers Map to object
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
},
);
socket.on("disconnect", () => {
console.log("🔌 Client disconnected:", socket.id);
// 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`,
);
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;
globalThis.__socketIO = io
console.log('✅ Socket.IO initialized on /api/socket')
return io
}

View File

@@ -12,7 +12,7 @@ services:
- .env
volumes:
- ./public:/app/public
- ./data:/app/data
- ./data:/app/apps/web/data
- ./uploads:/app/uploads
labels:
# ── Traefik Routers ───────────────────────────────────