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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./public:/app/public
|
||||
- ./data:/app/data
|
||||
- ./data:/app/apps/web/data
|
||||
- ./uploads:/app/uploads
|
||||
labels:
|
||||
# ── Traefik Routers ───────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user