diff --git a/src/App.jsx b/src/App.jsx index ab94f58..57bfbec 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -274,10 +274,10 @@ function Dashboard() { }, { id: 'wordchain', - title: '끝말잇기', + title: t('games.wordchainTitle'), icon: GameIcon, path: '/games/wordchain', - description: '영어 끝말잇기' + description: t('games.wordchainDesc') }, ], }, diff --git a/src/api/axios.js b/src/api/axios.js index 944cc96..ae97915 100644 --- a/src/api/axios.js +++ b/src/api/axios.js @@ -42,18 +42,29 @@ api.interceptors.response.use( // 401 에러 && 재시도하지 않을 경우 if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true + console.log('[Axios] 401 detected, attempting token refresh...') try { - // 토큰 갱신 시도 + // 토큰 갱신 시도 (API Gateway는 idToken을 기대) const session = await fetchAuthSession({ forceRefresh: true }) - const newToken = session.tokens?.accessToken?.toString() + console.log('[Axios] Session fetched:', !!session, 'tokens:', !!session?.tokens) + const newToken = session.tokens?.idToken?.toString() + console.log('[Axios] New token obtained:', !!newToken, 'length:', newToken?.length) if (newToken) { localStorage.setItem('accessToken', newToken) originalRequest.headers['Authorization'] = `Bearer ${newToken}` + console.log('[Axios] Retrying request with new token') return api(originalRequest) + } else { + console.log('[Axios] No token received, redirecting to login') + localStorage.removeItem('accessToken') + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } } } catch (refreshError) { + console.error('[Axios] Token refresh failed:', refreshError) try { await signOut() } catch (e) { diff --git a/src/api/badgeApi.js b/src/api/badgeApi.js index 52654bb..1df2de3 100644 --- a/src/api/badgeApi.js +++ b/src/api/badgeApi.js @@ -1,32 +1,13 @@ -import axios from 'axios' - -const badgeApi = axios.create({ - baseURL: import.meta.env.VITE_BADGE_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor for JWT token -badgeApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => Promise.reject(error) -) - -// Response interceptor for error handling -badgeApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Badge API Error:', error.response?.data || error.message) - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const badgeApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default badgeApi diff --git a/src/api/chatApi.js b/src/api/chatApi.js index a41d0fe..101ceb0 100644 --- a/src/api/chatApi.js +++ b/src/api/chatApi.js @@ -1,40 +1,13 @@ -import axios from 'axios' - -const chatApi = axios.create({ - baseURL: import.meta.env.VITE_CHAT_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -chatApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 시 로그인 페이지로 이동 -chatApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Chat API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const chatApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default chatApi diff --git a/src/api/grammarApi.js b/src/api/grammarApi.js index 54c4dee..72b97a9 100644 --- a/src/api/grammarApi.js +++ b/src/api/grammarApi.js @@ -1,40 +1,15 @@ -import axios from 'axios' - -const grammarApi = axios.create({ - baseURL: import.meta.env.VITE_GRAMMAR_API_URL || import.meta.env.VITE_API_URL, - timeout: 30000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor -grammarApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor -grammarApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Grammar API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// Grammar API는 AI 처리로 인해 timeout 30초 필요 +const GRAMMAR_TIMEOUT = 30000 + +const grammarApi = { + get: (url, config) => api.get(url, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + post: (url, data, config) => api.post(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + put: (url, data, config) => api.put(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), + delete: (url, config) => api.delete(url, { timeout: GRAMMAR_TIMEOUT, ...config }).then(res => res.data), +} export default grammarApi diff --git a/src/api/speakingApi.js b/src/api/speakingApi.js index 64a5b17..ef1c0e4 100644 --- a/src/api/speakingApi.js +++ b/src/api/speakingApi.js @@ -1,41 +1,15 @@ -import axios from 'axios' - -// Bedrock/Polly 사용으로 응답 시간(timeout) 제한 늘림 -const speakingApi = axios.create({ - baseURL: import.meta.env.VITE_API_URL, - timeout: 30000, // 30초 - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -speakingApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 처리 및 데이터 추출 -speakingApi.interceptors.response.use( - (response) => response.data, // response.data를 바로 반환하도록 설정 - (error) => { - console.error('Speaking API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// Bedrock/Polly 사용으로 timeout 30초 필요 +const SPEAKING_TIMEOUT = 30000 + +const speakingApi = { + get: (url, config) => api.get(url, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + post: (url, data, config) => api.post(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + put: (url, data, config) => api.put(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), + delete: (url, config) => api.delete(url, { timeout: SPEAKING_TIMEOUT, ...config }).then(res => res.data), +} export default speakingApi \ No newline at end of file diff --git a/src/api/vocabApi.js b/src/api/vocabApi.js index 0c78af4..1e25f4d 100644 --- a/src/api/vocabApi.js +++ b/src/api/vocabApi.js @@ -1,40 +1,13 @@ -import axios from 'axios' - -const vocabApi = axios.create({ - baseURL: import.meta.env.VITE_VOCAB_API_URL || import.meta.env.VITE_API_URL, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, -}) - -// Request interceptor - JWT 토큰 자동 추가 -vocabApi.interceptors.request.use( - (config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - } -) - -// Response interceptor - 401 에러 시 로그인 페이지로 이동 -vocabApi.interceptors.response.use( - (response) => response.data, - (error) => { - console.error('Vocab API Error:', error.response?.data || error.message) - - if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - window.location.href = '/login' - } - - return Promise.reject(error) - } -) +import api from './axios' + +// 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) +// response.data 자동 추출을 위한 래퍼 +const vocabApi = { + get: (url, config) => api.get(url, config).then(res => res.data), + post: (url, data, config) => api.post(url, data, config).then(res => res.data), + put: (url, data, config) => api.put(url, data, config).then(res => res.data), + patch: (url, data, config) => api.patch(url, data, config).then(res => res.data), + delete: (url, config) => api.delete(url, config).then(res => res.data), +} export default vocabApi diff --git a/src/aws-config.js b/src/aws-config.js index d4d0a9d..6d2b672 100644 --- a/src/aws-config.js +++ b/src/aws-config.js @@ -1,8 +1,8 @@ const awsConfig = { Auth: { Cognito: { - userPoolId: 'ap-northeast-2_ezDwzFCzR', - userPoolClientId: '4ns077jcr1pkue2vvisr6qdpu5', + userPoolId: import.meta.env.VITE_COGNITO_POOL_ID, + userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID, loginWith: { email: true, diff --git a/src/domains/freetalk/hooks/useChatWebSocket.js b/src/domains/freetalk/hooks/useChatWebSocket.js index fd6eaaa..5b84150 100644 --- a/src/domains/freetalk/hooks/useChatWebSocket.js +++ b/src/domains/freetalk/hooks/useChatWebSocket.js @@ -214,6 +214,97 @@ export function useChatWebSocket(roomId, userId) { })) }, + // 끝말잇기 게임 시작 + onWordchainStart: (data) => { + console.log('[useChatWebSocket] Wordchain started - FULL DATA:', JSON.stringify(data, null, 2)) + const gameData = data.data || data + // 서버 필드명 매핑: starterWord->currentWord, currentPlayerId->currentTurnUserId, timeLimit->turnTimeLimit + const wordchainState = { + status: 'PLAYING', + gameType: 'WORDCHAIN', + currentTurnUserId: gameData.currentPlayerId || gameData.currentTurnUserId, + currentWord: gameData.starterWord || gameData.currentWord, + nextLetter: gameData.nextLetter, + turnTimeLimit: gameData.timeLimit || gameData.turnTimeLimit || 15, + turnStartTime: gameData.turnStartTime || Date.now(), + scores: gameData.scores || {}, + players: gameData.players || gameData.activePlayers || [], + } + setGameState(wordchainState) + // PlayPage에서 사용할 수 있도록 sessionStorage에 저장 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(wordchainState)) + }, + + // 끝말잇기 정답 + onWordchainCorrect: (data) => { + console.log('[useChatWebSocket] Wordchain correct:', data) + const correctData = data.data || data + // 서버 필드명 매핑: word->currentWord, nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit + setGameState((prev) => { + const newState = { + ...prev, + currentWord: correctData.word || correctData.currentWord, + nextLetter: correctData.nextLetter || prev?.nextLetter, + currentTurnUserId: correctData.nextPlayerId || correctData.nextTurnUserId, + turnTimeLimit: correctData.nextTimeLimit || prev?.turnTimeLimit, + turnStartTime: correctData.turnStartTime || Date.now(), + scores: correctData.scores || prev?.scores, + usedWords: prev?.usedWords ? [...prev.usedWords, correctData.word] : [correctData.word], + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + // 끝말잇기 오답 + onWordchainWrong: (data) => { + console.log('[useChatWebSocket] Wordchain wrong:', data) + }, + + // 끝말잇기 타임아웃 + onWordchainTimeout: (data) => { + console.log('[useChatWebSocket] Wordchain timeout:', data) + const timeoutData = data.data || data + // 서버 필드명 매핑: nextPlayerId->currentTurnUserId, nextTimeLimit->turnTimeLimit + setGameState((prev) => { + const newState = { + ...prev, + currentTurnUserId: timeoutData.nextPlayerId || timeoutData.nextTurnUserId, + nextLetter: timeoutData.nextLetter || prev?.nextLetter, + turnTimeLimit: timeoutData.nextTimeLimit || prev?.turnTimeLimit, + turnStartTime: timeoutData.turnStartTime || Date.now(), + players: timeoutData.activePlayers || prev?.players, + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + + // 끝말잇기 게임 종료 + onWordchainEnd: (data) => { + console.log('[useChatWebSocket] Wordchain ended:', data) + const endData = data.data || data + setGameState((prev) => { + const newState = { + ...prev, + status: 'FINISHED', + winner: endData.winnerId ? { + id: endData.winnerId, + nickname: endData.winnerNickname, + } : null, + ranking: endData.ranking, + finalScores: endData.scores || prev?.scores, + usedWords: endData.usedWords || prev?.usedWords, + wordDefinitions: endData.wordDefinitions || {}, + } + // sessionStorage 업데이트 + sessionStorage.setItem(`wordchainState_${roomId}`, JSON.stringify(newState)) + return newState + }) + }, + onRoundStart: (data) => { console.log('[useChatWebSocket] Round started - FULL DATA:', JSON.stringify(data, null, 2)) // 실제 라운드 데이터 추출 (data.data에 중첩되어 있을 수 있음) diff --git a/src/domains/games/components/CreateGameRoomModal.jsx b/src/domains/games/components/CreateGameRoomModal.jsx index 4545f24..fc03238 100644 --- a/src/domains/games/components/CreateGameRoomModal.jsx +++ b/src/domains/games/components/CreateGameRoomModal.jsx @@ -22,14 +22,17 @@ const levelOptions = [ { value: 'ADVANCED', label: '고급', color: '#EF4444' }, ] -const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { +const CreateGameRoomModal = ({ open, onClose, onCreate, loading, gameType = 'CATCHMIND' }) => { + const isWordchain = gameType === 'WORDCHAIN' + const [formData, setFormData] = useState({ name: '', description: '', level: 'BEGINNER', - maxParticipants: 4, + maxParticipants: isWordchain ? 6 : 4, maxRounds: 5, roundTimeLimit: 60, + turnTimeLimit: 15, // 끝말잇기용 }) const handleChange = (field, value) => { @@ -46,9 +49,10 @@ const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { name: '', description: '', level: 'BEGINNER', - maxParticipants: 4, + maxParticipants: isWordchain ? 6 : 4, maxRounds: 5, roundTimeLimit: 60, + turnTimeLimit: 15, }) onClose?.() } @@ -190,59 +194,96 @@ const CreateGameRoomModal = ({ open, onClose, onCreate, loading }) => { /> - {/* 라운드 수 */} - - - - 라운드 수 - - - {formData.maxRounds}라운드 - + {/* 캐치마인드: 라운드 수 */} + {!isWordchain && ( + + + + 라운드 수 + + + {formData.maxRounds}라운드 + + + handleChange('maxRounds', value)} + min={3} + max={10} + step={1} + marks + sx={{ + color: GAME_COLORS.primary, + }} + /> - handleChange('maxRounds', value)} - min={3} - max={10} - step={1} - marks - sx={{ - color: GAME_COLORS.primary, - }} - /> - + )} - {/* 라운드 시간 */} - - - - 라운드 시간 - - - {formData.roundTimeLimit}초 - + {/* 캐치마인드: 라운드 시간 */} + {!isWordchain && ( + + + + 라운드 시간 + + + {formData.roundTimeLimit}초 + + + handleChange('roundTimeLimit', value)} + min={30} + max={120} + step={15} + marks={[ + { value: 30, label: '30초' }, + { value: 60, label: '60초' }, + { value: 90, label: '90초' }, + { value: 120, label: '120초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> - handleChange('roundTimeLimit', value)} - min={30} - max={120} - step={15} - marks={[ - { value: 30, label: '30초' }, - { value: 60, label: '60초' }, - { value: 90, label: '90초' }, - { value: 120, label: '120초' }, - ]} - sx={{ - color: GAME_COLORS.primary, - '& .MuiSlider-markLabel': { - fontSize: '0.65rem', - }, - }} - /> - + )} + + {/* 끝말잇기: 턴 시간 */} + {isWordchain && ( + + + + 턴 시간 + + + {formData.turnTimeLimit}초 + + + handleChange('turnTimeLimit', value)} + min={10} + max={30} + step={5} + marks={[ + { value: 10, label: '10초' }, + { value: 15, label: '15초' }, + { value: 20, label: '20초' }, + { value: 30, label: '30초' }, + ]} + sx={{ + color: GAME_COLORS.primary, + '& .MuiSlider-markLabel': { + fontSize: '0.65rem', + }, + }} + /> + + )} diff --git a/src/domains/games/components/wordchain/GameEndModal.jsx b/src/domains/games/components/wordchain/GameEndModal.jsx index abcb3eb..5b47a89 100644 --- a/src/domains/games/components/wordchain/GameEndModal.jsx +++ b/src/domains/games/components/wordchain/GameEndModal.jsx @@ -16,17 +16,38 @@ import { GAME_COLORS, GAME_TYPOGRAPHY } from '../../theme/gameTheme' /** * GameEndModal - 게임 종료 모달 + * @param {Object} winner - { id, nickname } or { userId, nickname } + * @param {Array} finalPlayers - 백엔드 ranking 배열 또는 기존 players 배열 + * - 백엔드: [{ playerId, nickname, score, eliminated }] + * - 기존: [{ userId, nickname, isAlive, wordsSubmitted }] */ -const GameEndModal = ({ open, winner, finalPlayers, onRestart, onExit, currentUserId }) => { - const sortedPlayers = [...(finalPlayers || [])] +const GameEndModal = ({ open, winner, finalPlayers, ranking, onRestart, onExit, currentUserId }) => { + // 백엔드 ranking 또는 기존 finalPlayers 사용 + const players = ranking || finalPlayers || [] + + // 데이터 정규화 - 백엔드 형식과 기존 형식 모두 지원 + const sortedPlayers = [...players] + .map(player => ({ + userId: player.playerId || player.userId, + nickname: player.nickname || player.userId || player.playerId, + score: player.score || 0, + isAlive: player.eliminated !== undefined ? !player.eliminated : player.isAlive, + wordsSubmitted: player.wordsSubmitted || player.score || 0, + })) .sort((a, b) => { // 생존자가 우선 if (a.isAlive && !b.isAlive) return -1 if (!a.isAlive && b.isAlive) return 1 - // 제출한 단어 수로 정렬 - return (b.wordsSubmitted || 0) - (a.wordsSubmitted || 0) + // 점수로 정렬 + return (b.score || 0) - (a.score || 0) }) + // winner 정규화 + const normalizedWinner = winner ? { + userId: winner.id || winner.userId || winner.playerId, + nickname: winner.nickname || winner.id || winner.userId, + } : null + return ( - {winner && ( + {normalizedWinner && ( - {winner.nickname || winner.userId} - {winner.userId === currentUserId && ' (나)'} + {normalizedWinner.nickname || normalizedWinner.userId} + {normalizedWinner.userId === currentUserId && ' (나)'} )} @@ -121,7 +142,7 @@ const GameEndModal = ({ open, winner, finalPlayers, onRestart, onExit, currentUs color: player.isAlive ? GAME_COLORS.primary : 'text.secondary', }} > - {player.wordsSubmitted || 0}단어 + {player.score || 0}점 ))} diff --git a/src/domains/games/pages/WordchainLobbyPage.jsx b/src/domains/games/pages/WordchainLobbyPage.jsx index a4b8542..1298b07 100644 --- a/src/domains/games/pages/WordchainLobbyPage.jsx +++ b/src/domains/games/pages/WordchainLobbyPage.jsx @@ -48,16 +48,14 @@ const WordchainLobbyPage = () => { setLoading(true) setError(null) - const params = {} + const params = { + gameType: 'WORDCHAIN', + } if (filters.level) params.level = filters.level if (filters.waitingOnly) params.status = 'WAITING' const response = await gameService.getRooms(params) - // WORDCHAIN 타입 필터링 - const wordchainRooms = (response.data.rooms || []).filter( - room => room.gameType === 'WORDCHAIN' - ) - setRooms(wordchainRooms) + setRooms(response.data.rooms || []) } catch (err) { console.error('Failed to fetch rooms:', err) setError('방 목록을 불러오는데 실패했습니다') diff --git a/src/domains/games/pages/WordchainPlayPage.jsx b/src/domains/games/pages/WordchainPlayPage.jsx index 383d656..f248609 100644 --- a/src/domains/games/pages/WordchainPlayPage.jsx +++ b/src/domains/games/pages/WordchainPlayPage.jsx @@ -56,6 +56,8 @@ const WordchainPlayPage = () => { players: [], usedWords: [], winner: null, + ranking: null, + finalScores: null, }) const [room, setRoom] = useState(null) @@ -63,38 +65,83 @@ const WordchainPlayPage = () => { const [timeLeft, setTimeLeft] = useState(15) const [showEndModal, setShowEndModal] = useState(false) const [submitting, setSubmitting] = useState(false) + const [timeoutSent, setTimeoutSent] = useState(false) // 타임아웃 중복 방지 const isMyTurn = gameState.currentTurnUserId === currentUserId + console.log('[WordchainPlayPage] Turn check:', { currentUserId, currentTurnUserId: gameState.currentTurnUserId, isMyTurn }) + + // WebSocket에서 온 players 배열을 객체 배열로 변환 (nickname 매핑) + const mapPlayersWithNickname = (playerIds, existingPlayers, participants) => { + if (!playerIds || !Array.isArray(playerIds)) return existingPlayers + + // playerIds가 이미 객체 배열이면 그대로 반환 + if (playerIds.length > 0 && typeof playerIds[0] === 'object') { + return playerIds + } + + // 문자열 배열이면 nickname 매핑 + return playerIds.map(userId => { + // 기존 players에서 찾기 + const existing = existingPlayers?.find(p => p.userId === userId) + if (existing) return existing + + // room.participants에서 찾기 + const participant = participants?.find(p => p.id === userId || p.participantId === userId || p.userId === userId) + if (participant) { + return { + userId, + nickname: participant.nickname || participant.name || userId.substring(0, 8), + isAlive: true, + } + } + + // 못 찾으면 userId로 표시 + return { + userId, + nickname: userId.substring(0, 8), + isAlive: true, + } + }) + } // WebSocket gameState 업데이트 반영 useEffect(() => { if (wsGameState) { console.log('[WordchainPlayPage] Received wsGameState:', wsGameState) - setGameState(prev => ({ - ...prev, - status: wsGameState.status ?? prev.status, - currentWord: wsGameState.currentWord ?? prev.currentWord, - nextLetter: wsGameState.nextLetter ?? prev.nextLetter, - currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId, - turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime, - turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15, - players: wsGameState.players ?? prev.players, - usedWords: wsGameState.usedWords ?? prev.usedWords, - winner: wsGameState.winner ?? prev.winner, - })) - - // 턴이 변경되면 타이머 리셋 - if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) { - setTimeLeft(wsGameState.turnTimeLimit ?? 15) - } + setGameState(prev => { + // 턴이 변경되면 타이머 리셋 + if (wsGameState.currentTurnUserId && wsGameState.currentTurnUserId !== prev.currentTurnUserId) { + setTimeLeft(wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15) + } + + // players 매핑 + const mappedPlayers = wsGameState.players + ? mapPlayersWithNickname(wsGameState.players, prev.players, room?.participants) + : prev.players + + return { + ...prev, + status: wsGameState.status ?? prev.status, + currentWord: wsGameState.currentWord ?? prev.currentWord, + nextLetter: wsGameState.nextLetter ?? prev.nextLetter, + currentTurnUserId: wsGameState.currentTurnUserId ?? prev.currentTurnUserId, + turnStartTime: wsGameState.turnStartTime ?? prev.turnStartTime, + turnTimeLimit: wsGameState.turnTimeLimit ?? prev.turnTimeLimit ?? 15, + players: mappedPlayers, + usedWords: wsGameState.usedWords ?? prev.usedWords, + winner: wsGameState.winner ?? prev.winner, + ranking: wsGameState.ranking ?? prev.ranking, + finalScores: wsGameState.finalScores ?? prev.finalScores, + } + }) // 게임 종료 처리 if (wsGameState.status === 'FINISHED' && !showEndModal) { setShowEndModal(true) } } - }, [wsGameState, showEndModal]) + }, [wsGameState, showEndModal, room]) // 초기 로드 및 WebSocket 연결 useEffect(() => { @@ -106,15 +153,33 @@ const WordchainPlayPage = () => { const roomResponse = await gameService.getRoom(roomId) setRoom(roomResponse.data) - // 게임 상태 조회 - let gameData - try { - const statusResponse = await wordchainService.getStatus(roomId) - gameData = statusResponse.data || statusResponse - } catch { - // 게임 상태가 없으면 시작 - const gameResponse = await wordchainService.start(roomId) - gameData = gameResponse.data || gameResponse + // sessionStorage에서 WORDCHAIN_START 데이터 확인 + const savedState = sessionStorage.getItem(`wordchainState_${roomId}`) + let gameData = null + + if (savedState) { + gameData = JSON.parse(savedState) + console.log('[WordchainPlayPage] Got saved wordchain state:', gameData) + // 페이지 이탈 시 삭제하도록 변경 (StrictMode 두 번 마운트 대응) + } else { + // sessionStorage에 없으면 API 조회 시도 + try { + const statusResponse = await wordchainService.getStatus(roomId) + gameData = statusResponse.data || statusResponse + console.log('[WordchainPlayPage] Got game status:', gameData) + } catch (err) { + // 게임 상태 조회 실패 시 WebSocket 이벤트 대기 + console.log('[WordchainPlayPage] Failed to get status, waiting for WebSocket:', err.message) + // 기본 상태로 시작하고 WebSocket에서 업데이트 받음 + gameData = { + currentWord: null, + nextLetter: null, + currentTurnUserId: null, + turnTimeLimit: 15, + players: roomResponse.data.participants || [], + usedWords: [], + } + } } setGameState({ @@ -157,13 +222,6 @@ const WordchainPlayPage = () => { const interval = setInterval(() => { setTimeLeft(prev => { if (prev <= 1) { - // 시간 초과 - if (isMyTurn && isConnected) { - console.log('[WordchainPlayPage] Timer expired, sending timeout') - wordchainService.timeout(roomId).catch(err => { - console.error('Failed to send timeout:', err) - }) - } return 0 } return prev - 1 @@ -171,7 +229,23 @@ const WordchainPlayPage = () => { }, 1000) return () => clearInterval(interval) - }, [gameState.status, isMyTurn, isConnected, roomId]) + }, [gameState.status]) + + // 타임아웃 처리 (한 번만 전송) + useEffect(() => { + if (timeLeft === 0 && isMyTurn && isConnected && !timeoutSent) { + console.log('[WordchainPlayPage] Timer expired, sending timeout') + setTimeoutSent(true) + wordchainService.timeout(roomId).catch(err => { + console.error('Failed to send timeout:', err) + }) + } + }, [timeLeft, isMyTurn, isConnected, timeoutSent, roomId]) + + // 턴 변경 시 타임아웃 플래그 리셋 + useEffect(() => { + setTimeoutSent(false) + }, [gameState.currentTurnUserId]) // 단어 제출 const handleSubmitWord = async (word) => { @@ -191,14 +265,16 @@ const WordchainPlayPage = () => { // 게임 종료 const handleStopGame = async () => { + disconnect() try { - disconnect() await wordchainService.stop(roomId) - sessionStorage.removeItem(`roomToken_${roomId}`) - navigate('/games/wordchain') } catch (err) { console.error('Failed to stop game:', err) + // 에러가 나도 무시하고 진행 } + sessionStorage.removeItem(`roomToken_${roomId}`) + sessionStorage.removeItem(`wordchainState_${roomId}`) + navigate('/games/wordchain') } // 재시작 @@ -389,6 +465,7 @@ const WordchainPlayPage = () => { open={showEndModal} winner={gameState.winner} finalPlayers={gameState.players} + ranking={gameState.ranking} onRestart={handleRestart} onExit={handleLeave} currentUserId={currentUserId} diff --git a/src/domains/games/pages/WordchainWaitingPage.jsx b/src/domains/games/pages/WordchainWaitingPage.jsx index e33642c..d8c8102 100644 --- a/src/domains/games/pages/WordchainWaitingPage.jsx +++ b/src/domains/games/pages/WordchainWaitingPage.jsx @@ -19,6 +19,7 @@ import { import ParticipantList from '../components/ParticipantList' import WaitingChat from '../components/WaitingChat' import { gameService } from '../services/gameService' +import { wordchainService } from '../services/wordchainService' import { GAME_COLORS } from '../theme/gameTheme' import { useAuth } from '../../../contexts/AuthContext' import { useThemeMode } from '../../../contexts/ThemeContext' @@ -137,7 +138,7 @@ const WordchainWaitingPage = () => { const handleStartGame = async () => { try { setStarting(true) - await gameService.startGame(roomId) + await wordchainService.start(roomId) navigate(`/games/wordchain/${roomId}/play`) } catch (err) { console.error('Failed to start game:', err) diff --git a/src/domains/games/services/gameService.js b/src/domains/games/services/gameService.js index 1c3958f..03844d4 100644 --- a/src/domains/games/services/gameService.js +++ b/src/domains/games/services/gameService.js @@ -20,7 +20,8 @@ export const gameRoomService = { const params = new URLSearchParams() // 게임방 필터 params.append('type', 'GAME') - params.append('gameType', 'CATCHMIND') + const gameType = filters.gameType || 'CATCHMIND' + params.append('gameType', gameType) // 백엔드는 소문자 level 값 사용 if (filters.status) params.append('status', filters.status) @@ -35,11 +36,11 @@ export const gameRoomService = { let data = response.data if (data?.rooms) { data.rooms = data.rooms.filter(room => - room.type === 'GAME' || room.gameType === 'CATCHMIND' + room.type === 'GAME' || room.gameType === gameType ) } else if (Array.isArray(data)) { data = data.filter(room => - room.type === 'GAME' || room.gameType === 'CATCHMIND' + room.type === 'GAME' || room.gameType === gameType ) } @@ -67,6 +68,19 @@ export const gameRoomService = { * @param {Object} data.gameSettings - 게임 설정 */ create: async (data) => { + const gameType = data.gameType || 'CATCHMIND' + const isWordchain = gameType === 'WORDCHAIN' + + // 게임 타입별 gameSettings 설정 + const gameSettings = isWordchain + ? { + turnTimeLimit: data.turnTimeLimit || 15, + } + : { + maxRounds: data.maxRounds || 5, + roundTimeLimit: data.roundTimeLimit || 60, + } + const payload = { name: data.name, description: data.description || '', @@ -75,11 +89,8 @@ export const gameRoomService = { isPrivate: data.isPrivate || false, password: data.password, type: 'GAME', - gameType: 'CATCHMIND', - gameSettings: { - maxRounds: data.maxRounds || 5, - roundTimeLimit: data.roundTimeLimit || 60, - }, + gameType: gameType, + gameSettings: gameSettings, } console.log('[gameService] create payload:', payload) diff --git a/src/domains/news/services/newsService.js b/src/domains/news/services/newsService.js index a417fd8..4a30eee 100644 --- a/src/domains/news/services/newsService.js +++ b/src/domains/news/services/newsService.js @@ -3,28 +3,33 @@ * 뉴스 영어 학습 관련 API 호출 */ -const API_URL = import.meta.env.VITE_API_URL +import api from '../../../api/axios' /** - * API 요청 헬퍼 + * API 요청 헬퍼 - 공통 axios 인스턴스 사용 (토큰 갱신 로직 포함) */ const fetchWithAuth = async (endpoint, options = {}) => { - const token = localStorage.getItem('accessToken') - const response = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), - ...options.headers, - }, - }) + const { method = 'GET', body, ...restOptions } = options + + const config = { + ...restOptions, + } - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new Error(error.message || 'API request failed') + if (method === 'GET') { + const response = await api.get(endpoint, config) + return response.data + } else if (method === 'POST') { + const response = await api.post(endpoint, body ? JSON.parse(body) : undefined, config) + return response.data + } else if (method === 'PUT') { + const response = await api.put(endpoint, body ? JSON.parse(body) : undefined, config) + return response.data + } else if (method === 'DELETE') { + const response = await api.delete(endpoint, config) + return response.data } - return response.json() + throw new Error(`Unsupported method: ${method}`) } /** diff --git a/src/i18n/translations.js b/src/i18n/translations.js index 5a8b7af..d5a1384 100644 --- a/src/i18n/translations.js +++ b/src/i18n/translations.js @@ -388,6 +388,8 @@ export const translations = { description: '재미있는 게임으로 영어 실력을 향상하세요', catchmindTitle: '캐치마인드', catchmindDesc: '그림 맞추기 게임', + wordchainTitle: '끝말잇기', + wordchainDesc: '영어 끝말잇기', }, // News @@ -837,6 +839,8 @@ export const translations = { description: 'Improve your English with fun games', catchmindTitle: 'Catchmind', catchmindDesc: 'Drawing guessing game', + wordchainTitle: 'Word Chain', + wordchainDesc: 'English word chain game', }, // News diff --git a/src/layouts/MainLayout/HorizontalNav/index.jsx b/src/layouts/MainLayout/HorizontalNav/index.jsx index 0c6e3ff..fb62d41 100644 --- a/src/layouts/MainLayout/HorizontalNav/index.jsx +++ b/src/layouts/MainLayout/HorizontalNav/index.jsx @@ -141,6 +141,13 @@ const HorizontalNav = () => { path: '/games/catchmind', desc: t('games.catchmindDesc') }, + { + id: 'wordchain', + label: t('games.wordchainTitle') || '끝말잇기', + icon: GameIcon, + path: '/games/wordchain', + desc: t('games.wordchainDesc') || '영어 끝말잇기' + }, ], }, {