From aac551a55929fc45bea7ee0d8e51cb7abbdaf074 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 12:10:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20word=20chain=20(=EB=81=9D?= =?UTF-8?q?=EB=A7=90=EC=9E=87=EA=B8=B0)=20game=20with=20dictionary=20API?= =?UTF-8?q?=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WordChainSession model with time limit, scoring, player management - Add WordChainService with game logic (start, submit, timeout, stop) - Add DictionaryService for word validation via Free Dictionary API - Add WordChainHandler REST API endpoints (/wordchain/start, submit, etc.) - Add WordChainFunction to SAM template - Add WORDCHAIN_* message types for WebSocket broadcasts - Fix command result domain (use chat domain for chat commands) - Add unit tests for WordChainSession and DictionaryService Closes #524, #525, #526, #527, #528 --- .../domain/chatting/enums/MessageType.java | 9 + .../chatting/exception/ChattingErrorCode.java | 4 + .../chatting/handler/WordChainHandler.java | 382 ++++++++++++++++ .../websocket/WebSocketMessageHandler.java | 41 +- .../chatting/model/WordChainSession.java | 206 +++++++++ .../WordChainSessionRepository.java | 93 ++++ .../chatting/service/DictionaryService.java | 220 +++++++++ .../chatting/service/WordChainService.java | 431 ++++++++++++++++++ .../model/WordChainSessionSpec.groovy | 271 +++++++++++ .../service/DictionaryServiceSpec.groovy | 54 +++ ServerlessFunction/template.yaml | 65 +++ 11 files changed, 1767 insertions(+), 9 deletions(-) create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java create mode 100644 ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy create mode 100644 ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index fddc60b7..c0fdc428 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -29,6 +29,15 @@ public enum MessageType { POLL_VOTE("poll_vote", "투표 참여"), POLL_END("poll_end", "투표 종료"), + // 끝말잇기(Word Chain) 게임 메시지 타입 + WORDCHAIN_START("wordchain_start", "끝말잇기 시작"), + WORDCHAIN_TURN("wordchain_turn", "턴 변경"), + WORDCHAIN_CORRECT("wordchain_correct", "정답"), + WORDCHAIN_WRONG("wordchain_wrong", "오답"), + WORDCHAIN_TIMEOUT("wordchain_timeout", "시간 초과"), + WORDCHAIN_ELIMINATED("wordchain_eliminated", "탈락"), + WORDCHAIN_END("wordchain_end", "끝말잇기 종료"), + // 유틸리티 메시지 타입 CLEAR_CHAT("clear_chat", "채팅 삭제"), LEAVE_ROOM("leave_room", "채팅방 나가기"); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java index ad599b53..9e6c8faf 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/exception/ChattingErrorCode.java @@ -44,6 +44,10 @@ public enum ChattingErrorCode implements DomainErrorCode { GAME_NOT_ALLOWED_IN_CHAT_ROOM("GAME_007", "게임은 게임 방에서만 시작할 수 있습니다", 400), GAME_RESTART_NOT_ALLOWED("GAME_008", "게임 진행 중에는 재시작할 수 없습니다", 400), GAME_START_NOT_HOST("GAME_009", "방장만 게임을 시작할 수 있습니다", 403), + GAME_ACTION_FAILED("GAME_010", "게임 액션 처리에 실패했습니다", 400), + + // 일반 입력 에러 + INVALID_INPUT("INPUT_001", "유효하지 않은 입력입니다", 400), ; private static final String DOMAIN = "CHATTING"; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java new file mode 100644 index 00000000..654a42eb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/WordChainHandler.java @@ -0,0 +1,382 @@ +package com.mzc.secondproject.serverless.domain.chatting.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.mzc.secondproject.serverless.common.router.HandlerRouter; +import com.mzc.secondproject.serverless.common.router.Route; +import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.common.util.WebSocketBroadcaster; +import com.mzc.secondproject.serverless.common.util.WebSocketMessageHelper; +import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; +import com.mzc.secondproject.serverless.domain.chatting.exception.ChattingErrorCode; +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.WordChainSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.service.WordChainService; +import com.mzc.secondproject.serverless.domain.chatting.service.WordChainService.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; + +/** + * 끝말잇기(Word Chain) 게임 REST API 핸들러 + */ +public class WordChainHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(WordChainHandler.class); + private static final String DOMAIN_WORDCHAIN = "wordchain"; + + private final WordChainService wordChainService; + private final WordChainSessionRepository sessionRepository; + private final ConnectionRepository connectionRepository; + private final WebSocketBroadcaster broadcaster; + private final HandlerRouter router; + + /** + * 기본 생성자 (Lambda에서 사용) + */ + public WordChainHandler() { + this(new WordChainService(), + new WordChainSessionRepository(), + new ConnectionRepository(), + new WebSocketBroadcaster()); + } + + /** + * 의존성 주입 생성자 (테스트 용이성) + */ + public WordChainHandler(WordChainService wordChainService, + WordChainSessionRepository sessionRepository, + ConnectionRepository connectionRepository, + WebSocketBroadcaster broadcaster) { + this.wordChainService = wordChainService; + this.sessionRepository = sessionRepository; + this.connectionRepository = connectionRepository; + this.broadcaster = broadcaster; + this.router = initRouter(); + } + + private HandlerRouter initRouter() { + return new HandlerRouter().addRoutes( + Route.postAuth("/rooms/{roomId}/wordchain/start", this::startGame), + Route.postAuth("/rooms/{roomId}/wordchain/submit", this::submitWord), + Route.postAuth("/rooms/{roomId}/wordchain/timeout", this::handleTimeout), + Route.postAuth("/rooms/{roomId}/wordchain/stop", this::stopGame), + Route.getAuth("/rooms/{roomId}/wordchain/status", this::getGameStatus) + ); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { + logger.info("Received request: {} {}", request.getHttpMethod(), request.getPath()); + return router.route(request); + } + + /** + * POST /rooms/{roomId}/wordchain/start - 게임 시작 + */ + private APIGatewayProxyResponseEvent startGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + GameStartResult result = wordChainService.startGame(roomId, userId); + + if (!result.success()) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_START_FAILED, result.error()); + } + + // WebSocket으로 게임 시작 알림 브로드캐스트 + broadcastGameStart(roomId, result); + + Map response = buildGameStatusResponse(result.session()); + return ResponseGenerator.ok("Word Chain game started", response); + } + + /** + * POST /rooms/{roomId}/wordchain/submit - 단어 제출 + */ + private APIGatewayProxyResponseEvent submitWord(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + @SuppressWarnings("unchecked") + Map body = ResponseGenerator.gson().fromJson(request.getBody(), Map.class); + String word = body.get("word"); + + if (word == null || word.isBlank()) { + return ResponseGenerator.fail(ChattingErrorCode.INVALID_INPUT, "단어를 입력해주세요."); + } + + WordSubmitResult result = wordChainService.submitWord(roomId, userId, word); + + // 결과에 따라 브로드캐스트 + broadcastWordResult(roomId, result); + + return buildSubmitResponse(result); + } + + /** + * POST /rooms/{roomId}/wordchain/timeout - 타임아웃 처리 + */ + private APIGatewayProxyResponseEvent handleTimeout(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + WordSubmitResult result = wordChainService.handleTimeout(roomId, userId); + + // 타임아웃 결과 브로드캐스트 + broadcastWordResult(roomId, result); + + return buildSubmitResponse(result); + } + + /** + * POST /rooms/{roomId}/wordchain/stop - 게임 중단 + */ + private APIGatewayProxyResponseEvent stopGame(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + WordSubmitResult result = wordChainService.stopGame(roomId, userId); + + if (result.type() == WordSubmitResult.ResultType.ERROR) { + return ResponseGenerator.fail(ChattingErrorCode.GAME_STOP_FAILED, result.error()); + } + + // 게임 종료 브로드캐스트 + broadcastWordResult(roomId, result); + + return ResponseGenerator.ok("Game stopped", Map.of("message", "게임이 종료되었습니다.")); + } + + /** + * GET /rooms/{roomId}/wordchain/status - 게임 상태 조회 + */ + private APIGatewayProxyResponseEvent getGameStatus(APIGatewayProxyRequestEvent request, String userId) { + String roomId = request.getPathParameters().get("roomId"); + + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return ResponseGenerator.ok("No active game", Map.of("gameStatus", "NONE")); + } + + Map response = buildGameStatusResponse(optSession.get()); + return ResponseGenerator.ok("Game status retrieved", response); + } + + /** + * 게임 상태 응답 빌드 + */ + private Map buildGameStatusResponse(WordChainSession session) { + Map response = new LinkedHashMap<>(); + response.put("sessionId", session.getSessionId()); + response.put("gameStatus", session.getStatus()); + response.put("currentRound", session.getCurrentRound()); + response.put("currentPlayerId", session.getCurrentPlayerId()); + response.put("currentWord", session.getCurrentWord()); + response.put("nextLetter", session.getNextLetter()); + response.put("timeLimit", session.getTimeLimit()); + response.put("turnStartTime", session.getTurnStartTime()); + response.put("serverTime", System.currentTimeMillis()); + response.put("activePlayers", session.getActivePlayers()); + response.put("eliminatedPlayers", session.getEliminatedPlayers()); + response.put("scores", session.getScores() != null ? session.getScores() : Map.of()); + response.put("usedWords", session.getUsedWords()); + return response; + } + + /** + * 단어 제출 결과 응답 빌드 + */ + private APIGatewayProxyResponseEvent buildSubmitResponse(WordSubmitResult result) { + Map response = new LinkedHashMap<>(); + response.put("resultType", result.type().name()); + + switch (result.type()) { + case CORRECT -> { + response.put("word", result.word()); + response.put("definition", result.definition()); + response.put("phonetic", result.phonetic()); + response.put("score", result.score()); + response.put("nextLetter", result.nextLetter()); + response.put("nextPlayerId", result.nextPlayerId()); + response.put("nextTimeLimit", result.nextTimeLimit()); + return ResponseGenerator.ok("Correct!", response); + } + case WRONG_LETTER, INVALID_WORD -> { + response.put("error", result.error()); + return ResponseGenerator.ok("Wrong answer", response); + } + case TIMEOUT -> { + response.put("eliminatedPlayerId", result.eliminatedPlayerId()); + response.put("eliminatedNickname", result.eliminatedNickname()); + response.put("nextPlayerId", result.nextPlayerId()); + response.put("nextTimeLimit", result.nextTimeLimit()); + return ResponseGenerator.ok("Timeout", response); + } + case GAME_END -> { + response.put("winnerId", result.winnerId()); + response.put("winnerNickname", result.winnerNickname()); + response.put("ranking", result.ranking()); + if (result.session() != null) { + response.put("usedWords", result.session().getUsedWords()); + response.put("wordDefinitions", result.session().getWordDefinitions()); + } + return ResponseGenerator.ok("Game ended", response); + } + case ERROR -> { + return ResponseGenerator.fail(ChattingErrorCode.GAME_ACTION_FAILED, result.error()); + } + default -> { + return ResponseGenerator.fail(ChattingErrorCode.GAME_ACTION_FAILED, "Unknown result type"); + } + } + } + + // ========== WebSocket Broadcast Methods ========== + + /** + * 게임 시작 브로드캐스트 + */ + private void broadcastGameStart(String roomId, GameStartResult result) { + WordChainSession session = result.session(); + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + String message = String.format(""" + 🎮 끝말잇기 시작! + 시작 단어: %s + 다음 글자: '%c' + + 첫 번째 차례: %s + 제한 시간: %d초 + """, + result.starterWord(), + result.nextLetter(), + result.firstPlayerId(), + session.getTimeLimit()); + + Map payload = new LinkedHashMap<>(); + payload.put("domain", DOMAIN_WORDCHAIN); + payload.put("messageId", messageId); + payload.put("roomId", roomId); + payload.put("userId", "SYSTEM"); + payload.put("content", message); + payload.put("messageType", MessageType.WORDCHAIN_START.getCode()); + payload.put("createdAt", now); + payload.put("timestamp", serverTime); + payload.put("sessionId", session.getSessionId()); + payload.put("starterWord", result.starterWord()); + payload.put("nextLetter", result.nextLetter()); + payload.put("currentPlayerId", result.firstPlayerId()); + payload.put("timeLimit", session.getTimeLimit()); + payload.put("turnStartTime", session.getTurnStartTime()); + payload.put("serverTime", serverTime); + payload.put("players", session.getPlayers()); + payload.put("activePlayers", session.getActivePlayers()); + + broadcastToRoom(roomId, payload); + logger.info("WordChain game start broadcasted: roomId={}, starterWord={}", + roomId, result.starterWord()); + } + + /** + * 단어 제출 결과 브로드캐스트 + */ + private void broadcastWordResult(String roomId, WordSubmitResult result) { + String messageId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long serverTime = System.currentTimeMillis(); + + Map payload = new LinkedHashMap<>(); + payload.put("domain", DOMAIN_WORDCHAIN); + payload.put("messageId", messageId); + payload.put("roomId", roomId); + payload.put("userId", "SYSTEM"); + payload.put("createdAt", now); + payload.put("timestamp", serverTime); + payload.put("serverTime", serverTime); + payload.put("resultType", result.type().name()); + + switch (result.type()) { + case CORRECT -> { + payload.put("messageType", MessageType.WORDCHAIN_CORRECT.getCode()); + payload.put("content", String.format("✅ %s: \"%s\" (+%d점)\n뜻: %s\n다음 글자: '%c'", + result.playerNickname(), + result.word(), + result.score(), + result.definition() != null ? result.definition() : "(정의 없음)", + result.nextLetter())); + payload.put("word", result.word()); + payload.put("definition", result.definition()); + payload.put("phonetic", result.phonetic()); + payload.put("score", result.score()); + payload.put("nextLetter", result.nextLetter()); + payload.put("nextPlayerId", result.nextPlayerId()); + payload.put("nextTimeLimit", result.nextTimeLimit()); + payload.put("playerNickname", result.playerNickname()); + if (result.session() != null) { + payload.put("turnStartTime", result.session().getTurnStartTime()); + payload.put("scores", result.session().getScores()); + } + } + case WRONG_LETTER -> { + payload.put("messageType", MessageType.WORDCHAIN_WRONG.getCode()); + payload.put("content", result.error()); + payload.put("error", result.error()); + } + case INVALID_WORD -> { + payload.put("messageType", MessageType.WORDCHAIN_WRONG.getCode()); + payload.put("content", "❌ " + result.error()); + payload.put("error", result.error()); + } + case TIMEOUT -> { + payload.put("messageType", MessageType.WORDCHAIN_TIMEOUT.getCode()); + payload.put("content", String.format("⏰ %s 시간 초과! 탈락!", + result.eliminatedNickname())); + payload.put("eliminatedPlayerId", result.eliminatedPlayerId()); + payload.put("eliminatedNickname", result.eliminatedNickname()); + payload.put("nextPlayerId", result.nextPlayerId()); + payload.put("nextTimeLimit", result.nextTimeLimit()); + if (result.session() != null) { + payload.put("nextLetter", result.session().getNextLetter()); + payload.put("turnStartTime", result.session().getTurnStartTime()); + payload.put("activePlayers", result.session().getActivePlayers()); + } + } + case GAME_END -> { + payload.put("messageType", MessageType.WORDCHAIN_END.getCode()); + String winnerMsg = result.winnerId() != null + ? String.format("🏆 승자: %s!", result.winnerNickname()) + : "게임 종료!"; + payload.put("content", winnerMsg); + payload.put("winnerId", result.winnerId()); + payload.put("winnerNickname", result.winnerNickname()); + payload.put("ranking", result.ranking()); + if (result.session() != null) { + payload.put("usedWords", result.session().getUsedWords()); + payload.put("wordDefinitions", result.session().getWordDefinitions()); + payload.put("scores", result.session().getScores()); + } + } + case ERROR -> { + // 에러는 브로드캐스트하지 않음 (요청자에게만 응답) + return; + } + } + + broadcastToRoom(roomId, payload); + logger.info("WordChain result broadcasted: roomId={}, type={}", roomId, result.type()); + } + + /** + * 방에 메시지 브로드캐스트 + */ + private void broadcastToRoom(String roomId, Map payload) { + List connections = connectionRepository.findByRoomId(roomId); + String jsonPayload = ResponseGenerator.gson().toJson(payload); + broadcaster.broadcast(connections, jsonPayload); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java index f8da9d75..5515cc1b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/handler/websocket/WebSocketMessageHandler.java @@ -362,13 +362,13 @@ private Map handleRoundTimeout(MessagePayload payload) { */ private Map handleCommandResult(CommandResult result, String roomId, String userId) { List connections = connectionRepository.findByRoomId(roomId); - + // GAME_START는 특별 처리 (출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.GAME_START && result.data() instanceof GameService.GameStartResult gameResult) { broadcastGameStart(connections, result, gameResult, roomId); return WebSocketEventUtil.ok("Command executed"); } - + // ROUND_END는 특별 처리 (다음 출제자에게만 제시어 전송 + serverTime 포함) if (result.messageType() == MessageType.ROUND_END && result.data() instanceof Map) { @SuppressWarnings("unchecked") @@ -376,14 +376,17 @@ private Map handleCommandResult(CommandResult result, String roo broadcastRoundEnd(connections, result, data, roomId); return WebSocketEventUtil.ok("Command executed"); } - - // 일반 시스템 메시지 (게임 관련 명령어 결과) + + // 일반 시스템 메시지 String messageId = UUID.randomUUID().toString(); String now = Instant.now().toString(); - + + // 메시지 타입에 따라 domain 결정 + String domain = determineDomain(result.messageType()); + // domain 필드 포함을 위해 Map으로 생성 Map systemMessage = new HashMap<>(); - systemMessage.put("domain", WebSocketMessageHelper.DOMAIN_GAME); + systemMessage.put("domain", domain); systemMessage.put("messageId", messageId); systemMessage.put("roomId", roomId); systemMessage.put("userId", "SYSTEM"); @@ -391,14 +394,34 @@ private Map handleCommandResult(CommandResult result, String roo systemMessage.put("messageType", result.messageType().getCode()); systemMessage.put("createdAt", now); systemMessage.put("timestamp", System.currentTimeMillis()); - + + // 추가 데이터가 있으면 포함 + if (result.data() != null) { + systemMessage.put("data", result.data()); + } + String broadcastPayload = gson.toJson(systemMessage); List failedConnections = broadcaster.broadcast(connections, broadcastPayload); cleanupFailedConnections(failedConnections); - - logger.info("Command result broadcasted: type={}, roomId={}", result.messageType(), roomId); + + logger.info("Command result broadcasted: type={}, domain={}, roomId={}", result.messageType(), domain, roomId); return WebSocketEventUtil.ok("Command executed"); } + + /** + * 메시지 타입에 따라 domain 결정 + */ + private String determineDomain(MessageType messageType) { + return switch (messageType) { + // 게임 관련 메시지 + case GAME_START, GAME_END, ROUND_START, ROUND_END, DRAWING, DRAWING_CLEAR, + CORRECT_ANSWER, SCORE_UPDATE, HINT -> WebSocketMessageHelper.DOMAIN_GAME; + // 방 상태 관련 메시지 + case ROOM_STATUS_CHANGE, HOST_CHANGE -> WebSocketMessageHelper.DOMAIN_ROOM; + // 채팅 관련 메시지 (기본값) + default -> WebSocketMessageHelper.DOMAIN_CHAT; + }; + } /** * GAME_START 메시지 브로드캐스트 - 출제자에게만 제시어 포함, serverTime 추가 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java new file mode 100644 index 00000000..aa4e2c8d --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSession.java @@ -0,0 +1,206 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.*; + +/** + * 끝말잇기 게임 세션 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class WordChainSession { + + private String pk; // WORDCHAIN#{sessionId} + private String sk; // METADATA + private String gsi1pk; // ROOM#{roomId} + private String gsi1sk; // WORDCHAIN#{createdAt} + + private String sessionId; + private String roomId; + private String gameType; // "wordchain" + + // 게임 상태 + private String status; // WAITING, PLAYING, FINISHED + private String startedBy; + private Long startedAt; + private Long endedAt; + + // 턴 정보 + private Integer currentRound; + private String currentPlayerId; + private String currentWord; + private Character nextLetter; // 다음 사람이 시작해야 할 글자 + private Long turnStartTime; + private Integer timeLimit; // 현재 라운드 시간 제한 (초) + + // 플레이어 관리 + private List players; // 전체 플레이어 (순서대로) + private List activePlayers; // 탈락하지 않은 플레이어 + private List eliminatedPlayers; // 탈락한 플레이어 + private Map scores; + + // 게임 기록 + private List usedWords; // 사용된 단어 목록 + private Map wordDefinitions; // 단어 -> 뜻 (게임 종료 후 학습용) + + // TTL + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1PK") + public String getGsi1pk() { + return gsi1pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI1") + @DynamoDbAttribute("GSI1SK") + public String getGsi1sk() { + return gsi1sk; + } + + // ========== 비즈니스 메서드 ========== + + /** + * 게임이 활성 상태인지 확인 + */ + public boolean isActive() { + return "PLAYING".equals(status); + } + + /** + * 현재 턴인지 확인 + */ + public boolean isCurrentTurn(String userId) { + return userId != null && userId.equals(currentPlayerId); + } + + /** + * 단어가 이미 사용되었는지 확인 + */ + public boolean isWordUsed(String word) { + return usedWords != null && usedWords.contains(word.toLowerCase()); + } + + /** + * 단어 추가 + */ + public void addUsedWord(String word, String definition) { + if (usedWords == null) { + usedWords = new ArrayList<>(); + } + usedWords.add(word.toLowerCase()); + + if (definition != null) { + if (wordDefinitions == null) { + wordDefinitions = new HashMap<>(); + } + wordDefinitions.put(word.toLowerCase(), definition); + } + } + + /** + * 플레이어 탈락 처리 + */ + public void eliminatePlayer(String userId) { + if (activePlayers != null) { + activePlayers.remove(userId); + } + if (eliminatedPlayers == null) { + eliminatedPlayers = new ArrayList<>(); + } + if (!eliminatedPlayers.contains(userId)) { + eliminatedPlayers.add(userId); + } + } + + /** + * 다음 플레이어 ID 반환 + */ + public String getNextPlayerId() { + if (activePlayers == null || activePlayers.isEmpty()) { + return null; + } + if (activePlayers.size() == 1) { + return activePlayers.get(0); // 마지막 1명 = 승자 + } + if (currentPlayerId == null) { + return activePlayers.get(0); + } + int currentIndex = activePlayers.indexOf(currentPlayerId); + if (currentIndex == -1) { + return activePlayers.get(0); + } + return activePlayers.get((currentIndex + 1) % activePlayers.size()); + } + + /** + * 점수 추가 + */ + public void addScore(String userId, int points) { + if (scores == null) { + scores = new HashMap<>(); + } + scores.merge(userId, points, Integer::sum); + } + + /** + * 게임 종료 조건 확인 (1명만 남음) + */ + public boolean isGameOver() { + return activePlayers == null || activePlayers.size() <= 1; + } + + /** + * 승자 반환 + */ + public String getWinner() { + if (activePlayers != null && activePlayers.size() == 1) { + return activePlayers.get(0); + } + return null; + } + + /** + * 시간 제한 계산 (라운드에 따라 점점 빨라짐) + * Round 1-2: 15초, Round 3-4: 13초, Round 5-6: 11초, Round 7-8: 9초, Round 9+: 8초 + */ + public static int calculateTimeLimit(int round) { + return Math.max(8, 15 - ((round - 1) / 2) * 2); + } + + /** + * 점수 계산 (빠른 응답 + 긴 단어 보너스) + */ + public static int calculateScore(long responseTimeMs, int wordLength, int timeLimit) { + int baseScore = 10; + + // 시간 보너스 (빠를수록 높음) + int remainingSeconds = timeLimit - (int)(responseTimeMs / 1000); + int timeBonus = Math.max(0, remainingSeconds); + + // 단어 길이 보너스 (5글자 이상부터) + int lengthBonus = Math.max(0, (wordLength - 4) * 2); + + return baseScore + timeBonus + lengthBonus; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java new file mode 100644 index 00000000..ca39ddf7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/WordChainSessionRepository.java @@ -0,0 +1,93 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +/** + * 끝말잇기 게임 세션 Repository + */ +public class WordChainSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(WordChainSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public WordChainSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced() + .table(TABLE_NAME, TableSchema.fromBean(WordChainSession.class)); + } + + public WordChainSessionRepository(DynamoDbTable table) { + this.table = table; + } + + /** + * 세션 저장 + */ + public void save(WordChainSession session) { + table.putItem(session); + logger.debug("Saved WordChainSession: {}", session.getSessionId()); + } + + /** + * 세션 ID로 조회 + */ + public Optional findById(String sessionId) { + Key key = Key.builder() + .partitionValue("WORDCHAIN#" + sessionId) + .sortValue("METADATA") + .build(); + WordChainSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 방의 활성 세션 조회 + */ + public Optional findActiveByRoomId(String roomId) { + return table.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("WORDCHAIN#") + .build())) + .items() + .stream() + .filter(WordChainSession::isActive) + .findFirst(); + } + + /** + * 세션 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue("WORDCHAIN#" + sessionId) + .sortValue("METADATA") + .build(); + table.deleteItem(key); + logger.debug("Deleted WordChainSession: {}", sessionId); + } + + /** + * 게임 종료 처리 + */ + public void finishGame(String sessionId, long endedAt, long ttl) { + findById(sessionId).ifPresent(session -> { + session.setStatus("FINISHED"); + session.setEndedAt(endedAt); + session.setTtl(ttl); + save(session); + logger.info("Finished WordChainSession: {}", sessionId); + }); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java new file mode 100644 index 00000000..84191f08 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryService.java @@ -0,0 +1,220 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 외부 사전 API 연동 서비스 + * Free Dictionary API (https://dictionaryapi.dev/) 사용 + */ +public class DictionaryService { + + private static final Logger logger = LoggerFactory.getLogger(DictionaryService.class); + private static final String API_BASE_URL = "https://api.dictionaryapi.dev/api/v2/entries/en/"; + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final HttpClient httpClient; + private final Gson gson; + + // 간단한 인메모리 캐시 (Lambda 인스턴스 내에서만 유효) + private final ConcurrentHashMap cache; + + public DictionaryService() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(TIMEOUT) + .build(); + this.gson = new Gson(); + this.cache = new ConcurrentHashMap<>(); + } + + /** + * 단어 검증 및 정의 조회 + * + * @param word 검증할 단어 + * @return 검증 결과 (유효 여부 + 정의) + */ + public DictionaryResult lookupWord(String word) { + if (word == null || word.isBlank()) { + return DictionaryResult.invalid("단어가 비어있습니다."); + } + + String normalizedWord = word.trim().toLowerCase(); + + // 캐시 확인 + if (cache.containsKey(normalizedWord)) { + logger.debug("Cache hit for word: {}", normalizedWord); + return cache.get(normalizedWord); + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(API_BASE_URL + normalizedWord)) + .timeout(TIMEOUT) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); + + DictionaryResult result = parseResponse(normalizedWord, response); + + // 캐시 저장 + cache.put(normalizedWord, result); + + return result; + + } catch (Exception e) { + logger.error("Dictionary API error for word '{}': {}", normalizedWord, e.getMessage()); + // API 실패 시 일단 유효한 것으로 처리 (fallback) + return DictionaryResult.validWithoutDefinition(normalizedWord); + } + } + + /** + * API 응답 파싱 + */ + private DictionaryResult parseResponse(String word, HttpResponse response) { + if (response.statusCode() == 404) { + return DictionaryResult.invalid("사전에 없는 단어입니다: " + word); + } + + if (response.statusCode() != 200) { + logger.warn("Unexpected API response: {} for word '{}'", response.statusCode(), word); + return DictionaryResult.validWithoutDefinition(word); + } + + try { + JsonArray jsonArray = gson.fromJson(response.body(), JsonArray.class); + if (jsonArray == null || jsonArray.isEmpty()) { + return DictionaryResult.invalid("사전에 없는 단어입니다: " + word); + } + + JsonObject firstEntry = jsonArray.get(0).getAsJsonObject(); + + // 발음 추출 (있으면) + String phonetic = extractPhonetic(firstEntry); + + // 첫 번째 정의 추출 + String definition = extractFirstDefinition(firstEntry); + + return DictionaryResult.valid(word, definition, phonetic); + + } catch (Exception e) { + logger.error("Failed to parse dictionary response for '{}': {}", word, e.getMessage()); + return DictionaryResult.validWithoutDefinition(word); + } + } + + /** + * 발음 기호 추출 + */ + private String extractPhonetic(JsonObject entry) { + try { + if (entry.has("phonetic")) { + return entry.get("phonetic").getAsString(); + } + if (entry.has("phonetics")) { + JsonArray phonetics = entry.getAsJsonArray("phonetics"); + for (JsonElement p : phonetics) { + JsonObject phoneticObj = p.getAsJsonObject(); + if (phoneticObj.has("text") && !phoneticObj.get("text").isJsonNull()) { + String text = phoneticObj.get("text").getAsString(); + if (!text.isBlank()) { + return text; + } + } + } + } + } catch (Exception e) { + logger.debug("Failed to extract phonetic: {}", e.getMessage()); + } + return null; + } + + /** + * 첫 번째 정의 추출 + */ + private String extractFirstDefinition(JsonObject entry) { + try { + if (!entry.has("meanings")) { + return null; + } + JsonArray meanings = entry.getAsJsonArray("meanings"); + if (meanings.isEmpty()) { + return null; + } + + JsonObject firstMeaning = meanings.get(0).getAsJsonObject(); + String partOfSpeech = firstMeaning.has("partOfSpeech") + ? firstMeaning.get("partOfSpeech").getAsString() + : ""; + + JsonArray definitions = firstMeaning.getAsJsonArray("definitions"); + if (definitions == null || definitions.isEmpty()) { + return null; + } + + String definition = definitions.get(0).getAsJsonObject() + .get("definition").getAsString(); + + return String.format("(%s) %s", partOfSpeech, definition); + + } catch (Exception e) { + logger.debug("Failed to extract definition: {}", e.getMessage()); + return null; + } + } + + /** + * 단어가 유효한지만 빠르게 확인 (정의 필요 없을 때) + */ + public boolean isValidWord(String word) { + return lookupWord(word).isValid(); + } + + // ========== Result DTO ========== + + public record DictionaryResult( + boolean valid, + String word, + String definition, + String phonetic, + String errorMessage + ) { + public static DictionaryResult valid(String word, String definition, String phonetic) { + return new DictionaryResult(true, word, definition, phonetic, null); + } + + public static DictionaryResult validWithoutDefinition(String word) { + return new DictionaryResult(true, word, null, null, null); + } + + public static DictionaryResult invalid(String errorMessage) { + return new DictionaryResult(false, null, null, null, errorMessage); + } + + public boolean isValid() { + return valid; + } + + public Optional getDefinition() { + return Optional.ofNullable(definition); + } + + public Optional getPhonetic() { + return Optional.ofNullable(phonetic); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java new file mode 100644 index 00000000..c8298468 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/WordChainService.java @@ -0,0 +1,431 @@ +package com.mzc.secondproject.serverless.domain.chatting.service; + +import com.mzc.secondproject.serverless.domain.chatting.model.Connection; +import com.mzc.secondproject.serverless.domain.chatting.model.WordChainSession; +import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.WordChainSessionRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 끝말잇기 게임 서비스 + */ +public class WordChainService { + + private static final Logger logger = LoggerFactory.getLogger(WordChainService.class); + + // 게임 시작 단어 후보 (쉬운 3-5글자 단어) + private static final List STARTER_WORDS = List.of( + "apple", "house", "water", "happy", "green", "music", "paper", + "table", "chair", "phone", "smile", "dream", "light", "earth", + "ocean", "river", "cloud", "sugar", "lemon", "tiger", "eagle" + ); + + private final WordChainSessionRepository sessionRepository; + private final ConnectionRepository connectionRepository; + private final UserRepository userRepository; + private final DictionaryService dictionaryService; + private final Random random; + + public WordChainService() { + this(new WordChainSessionRepository(), + new ConnectionRepository(), + new UserRepository(), + new DictionaryService()); + } + + public WordChainService(WordChainSessionRepository sessionRepository, + ConnectionRepository connectionRepository, + UserRepository userRepository, + DictionaryService dictionaryService) { + this.sessionRepository = sessionRepository; + this.connectionRepository = connectionRepository; + this.userRepository = userRepository; + this.dictionaryService = dictionaryService; + this.random = new Random(); + } + + /** + * 게임 시작 + */ + public GameStartResult startGame(String roomId, String userId) { + // 이미 진행 중인 게임 확인 + Optional existingSession = sessionRepository.findActiveByRoomId(roomId); + if (existingSession.isPresent()) { + return GameStartResult.error("이미 진행 중인 게임이 있습니다."); + } + + // 접속자 확인 + List connections = connectionRepository.findByRoomId(roomId); + if (connections.size() < 2) { + return GameStartResult.error("최소 2명 이상 필요합니다."); + } + + // 플레이어 순서 랜덤 셔플 + List players = connections.stream() + .map(Connection::getUserId) + .collect(Collectors.toList()); + Collections.shuffle(players); + + // 시작 단어 선택 + String starterWord = STARTER_WORDS.get(random.nextInt(STARTER_WORDS.size())); + char nextLetter = starterWord.charAt(starterWord.length() - 1); + + // 세션 생성 + String sessionId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long currentTime = System.currentTimeMillis(); + int timeLimit = WordChainSession.calculateTimeLimit(1); + + WordChainSession session = WordChainSession.builder() + .pk("WORDCHAIN#" + sessionId) + .sk("METADATA") + .gsi1pk("ROOM#" + roomId) + .gsi1sk("WORDCHAIN#" + now) + .sessionId(sessionId) + .roomId(roomId) + .gameType("wordchain") + .status("PLAYING") + .startedBy(userId) + .startedAt(currentTime) + .currentRound(1) + .currentPlayerId(players.get(0)) + .currentWord(starterWord) + .nextLetter(nextLetter) + .turnStartTime(currentTime) + .timeLimit(timeLimit) + .players(players) + .activePlayers(new ArrayList<>(players)) + .eliminatedPlayers(new ArrayList<>()) + .scores(new HashMap<>()) + .usedWords(new ArrayList<>(List.of(starterWord.toLowerCase()))) + .wordDefinitions(new HashMap<>()) + .build(); + + // 시작 단어 정의 조회 + DictionaryService.DictionaryResult starterResult = dictionaryService.lookupWord(starterWord); + if (starterResult.getDefinition().isPresent()) { + session.getWordDefinitions().put(starterWord.toLowerCase(), starterResult.getDefinition().get()); + } + + sessionRepository.save(session); + + logger.info("WordChain game started: sessionId={}, roomId={}, players={}", + sessionId, roomId, players.size()); + + return GameStartResult.success(session, starterWord, nextLetter, players.get(0)); + } + + /** + * 단어 제출 + */ + public WordSubmitResult submitWord(String roomId, String userId, String word) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + + WordChainSession session = optSession.get(); + + // 본인 턴인지 확인 + if (!session.isCurrentTurn(userId)) { + return WordSubmitResult.error("당신의 차례가 아닙니다."); + } + + // 시간 초과 확인 + long elapsed = System.currentTimeMillis() - session.getTurnStartTime(); + if (elapsed > session.getTimeLimit() * 1000L) { + return handleTimeout(session, userId); + } + + String normalizedWord = word.trim().toLowerCase(); + + // 첫 글자 확인 + if (normalizedWord.charAt(0) != session.getNextLetter()) { + return WordSubmitResult.wrongLetter(session.getNextLetter()); + } + + // 중복 단어 확인 + if (session.isWordUsed(normalizedWord)) { + return WordSubmitResult.error("이미 사용된 단어입니다: " + normalizedWord); + } + + // 사전 API로 유효성 검증 + DictionaryService.DictionaryResult dictResult = dictionaryService.lookupWord(normalizedWord); + if (!dictResult.isValid()) { + return WordSubmitResult.invalidWord(dictResult.errorMessage()); + } + + // 정답 처리 + int score = WordChainSession.calculateScore(elapsed, normalizedWord.length(), session.getTimeLimit()); + session.addScore(userId, score); + session.addUsedWord(normalizedWord, dictResult.getDefinition().orElse(null)); + + // 다음 턴 준비 + char nextLetter = normalizedWord.charAt(normalizedWord.length() - 1); + String nextPlayerId = session.getNextPlayerId(); + int nextRound = session.getCurrentRound() + 1; + int nextTimeLimit = WordChainSession.calculateTimeLimit(nextRound); + + session.setCurrentRound(nextRound); + session.setCurrentWord(normalizedWord); + session.setNextLetter(nextLetter); + session.setCurrentPlayerId(nextPlayerId); + session.setTurnStartTime(System.currentTimeMillis()); + session.setTimeLimit(nextTimeLimit); + + sessionRepository.save(session); + + String nickname = getNickname(userId); + + logger.info("Word accepted: sessionId={}, word={}, player={}, score={}", + session.getSessionId(), normalizedWord, userId, score); + + return WordSubmitResult.correct( + session, + normalizedWord, + dictResult.getDefinition().orElse(null), + dictResult.getPhonetic().orElse(null), + score, + nextLetter, + nextPlayerId, + nextTimeLimit, + nickname + ); + } + + /** + * 타임아웃 처리 + */ + public WordSubmitResult handleTimeout(String roomId, String userId) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + return handleTimeout(optSession.get(), userId); + } + + private WordSubmitResult handleTimeout(WordChainSession session, String userId) { + // 플레이어 탈락 + session.eliminatePlayer(userId); + String nickname = getNickname(userId); + + logger.info("Player eliminated (timeout): sessionId={}, player={}", + session.getSessionId(), userId); + + // 게임 종료 확인 + if (session.isGameOver()) { + return finishGame(session, "TIMEOUT"); + } + + // 다음 턴 준비 + String nextPlayerId = session.getNextPlayerId(); + int nextRound = session.getCurrentRound() + 1; + int nextTimeLimit = WordChainSession.calculateTimeLimit(nextRound); + + session.setCurrentRound(nextRound); + session.setCurrentPlayerId(nextPlayerId); + session.setTurnStartTime(System.currentTimeMillis()); + session.setTimeLimit(nextTimeLimit); + + sessionRepository.save(session); + + return WordSubmitResult.timeout( + session, + userId, + nickname, + nextPlayerId, + nextTimeLimit + ); + } + + /** + * 게임 종료 + */ + public WordSubmitResult finishGame(WordChainSession session, String reason) { + long endTime = System.currentTimeMillis(); + long ttl = Instant.now().plusSeconds(7 * 24 * 60 * 60).getEpochSecond(); // 7일 보관 + + session.setStatus("FINISHED"); + session.setEndedAt(endTime); + session.setTtl(ttl); + sessionRepository.save(session); + + String winnerId = session.getWinner(); + String winnerNickname = winnerId != null ? getNickname(winnerId) : null; + + // 최종 순위 계산 + List ranking = buildRanking(session); + + logger.info("WordChain game finished: sessionId={}, winner={}, reason={}", + session.getSessionId(), winnerId, reason); + + return WordSubmitResult.gameEnd(session, winnerId, winnerNickname, ranking); + } + + /** + * 게임 강제 종료 + */ + public WordSubmitResult stopGame(String roomId, String userId) { + Optional optSession = sessionRepository.findActiveByRoomId(roomId); + if (optSession.isEmpty()) { + return WordSubmitResult.error("진행 중인 게임이 없습니다."); + } + + WordChainSession session = optSession.get(); + + // 게임 시작자만 종료 가능 + if (!userId.equals(session.getStartedBy())) { + return WordSubmitResult.error("게임 시작자만 종료할 수 있습니다."); + } + + return finishGame(session, "STOPPED"); + } + + /** + * 순위 계산 + */ + private List buildRanking(WordChainSession session) { + List ranking = new ArrayList<>(); + + // 점수 기준 정렬 + Map scores = session.getScores() != null + ? session.getScores() + : new HashMap<>(); + + // 활성 플레이어 (생존자) 먼저 + if (session.getActivePlayers() != null) { + for (String playerId : session.getActivePlayers()) { + ranking.add(new RankEntry( + playerId, + getNickname(playerId), + scores.getOrDefault(playerId, 0), + false + )); + } + } + + // 탈락 플레이어 (역순으로 - 나중에 탈락한 사람이 순위 높음) + if (session.getEliminatedPlayers() != null) { + List eliminated = new ArrayList<>(session.getEliminatedPlayers()); + Collections.reverse(eliminated); + for (String playerId : eliminated) { + ranking.add(new RankEntry( + playerId, + getNickname(playerId), + scores.getOrDefault(playerId, 0), + true + )); + } + } + + return ranking; + } + + /** + * 닉네임 조회 + */ + private String getNickname(String userId) { + return userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + } + + // ========== Result DTOs ========== + + public record GameStartResult( + boolean success, + String error, + WordChainSession session, + String starterWord, + Character nextLetter, + String firstPlayerId + ) { + public static GameStartResult success(WordChainSession session, String word, char letter, String playerId) { + return new GameStartResult(true, null, session, word, letter, playerId); + } + + public static GameStartResult error(String message) { + return new GameStartResult(false, message, null, null, null, null); + } + } + + public record WordSubmitResult( + ResultType type, + String error, + WordChainSession session, + // 정답 시 + String word, + String definition, + String phonetic, + int score, + Character nextLetter, + String nextPlayerId, + int nextTimeLimit, + String playerNickname, + // 타임아웃 시 + String eliminatedPlayerId, + String eliminatedNickname, + // 게임 종료 시 + String winnerId, + String winnerNickname, + List ranking + ) { + public enum ResultType { + CORRECT, WRONG_LETTER, INVALID_WORD, TIMEOUT, GAME_END, ERROR + } + + public static WordSubmitResult correct(WordChainSession session, String word, String definition, + String phonetic, int score, char nextLetter, + String nextPlayerId, int nextTimeLimit, String nickname) { + return new WordSubmitResult(ResultType.CORRECT, null, session, word, definition, phonetic, + score, nextLetter, nextPlayerId, nextTimeLimit, nickname, + null, null, null, null, null); + } + + public static WordSubmitResult wrongLetter(char expected) { + return new WordSubmitResult(ResultType.WRONG_LETTER, + String.format("'%c'로 시작하는 단어를 입력하세요.", expected), + null, null, null, null, 0, null, null, 0, null, + null, null, null, null, null); + } + + public static WordSubmitResult invalidWord(String reason) { + return new WordSubmitResult(ResultType.INVALID_WORD, reason, + null, null, null, null, 0, null, null, 0, null, + null, null, null, null, null); + } + + public static WordSubmitResult timeout(WordChainSession session, String eliminatedId, String eliminatedNick, + String nextPlayerId, int nextTimeLimit) { + return new WordSubmitResult(ResultType.TIMEOUT, null, session, null, null, null, 0, + session.getNextLetter(), nextPlayerId, nextTimeLimit, null, + eliminatedId, eliminatedNick, null, null, null); + } + + public static WordSubmitResult gameEnd(WordChainSession session, String winnerId, String winnerNick, + List ranking) { + return new WordSubmitResult(ResultType.GAME_END, null, session, null, null, null, 0, + null, null, 0, null, null, null, winnerId, winnerNick, ranking); + } + + public static WordSubmitResult error(String message) { + return new WordSubmitResult(ResultType.ERROR, message, null, null, null, null, 0, + null, null, 0, null, null, null, null, null, null); + } + } + + public record RankEntry( + String playerId, + String nickname, + int score, + boolean eliminated + ) { + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy new file mode 100644 index 00000000..0b87dc9b --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/WordChainSessionSpec.groovy @@ -0,0 +1,271 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification +import spock.lang.Unroll + +class WordChainSessionSpec extends Specification { + + def "calculateTimeLimit: 라운드별 시간 제한 계산"() { + expect: + WordChainSession.calculateTimeLimit(round) == expected + + where: + round | expected + 1 | 15 + 2 | 15 + 3 | 13 + 4 | 13 + 5 | 11 + 6 | 11 + 7 | 9 + 8 | 9 + 9 | 8 + 10 | 8 + 20 | 8 + } + + def "calculateScore: 기본 점수 계산"() { + when: + def score = WordChainSession.calculateScore(responseTimeMs, wordLength, timeLimit) + + then: + score == expected + + where: + responseTimeMs | wordLength | timeLimit | expected + 0 | 4 | 15 | 25 // base(10) + time(15) + length(0) + 5000 | 4 | 15 | 20 // base(10) + time(10) + length(0) + 10000 | 4 | 15 | 15 // base(10) + time(5) + length(0) + 15000 | 4 | 15 | 10 // base(10) + time(0) + length(0) + 0 | 7 | 15 | 31 // base(10) + time(15) + length(6) + 5000 | 6 | 15 | 24 // base(10) + time(10) + length(4) + } + + def "isActive: 게임 활성 상태 확인"() { + given: + def session = WordChainSession.builder() + .status(status) + .build() + + expect: + session.isActive() == expected + + where: + status | expected + "PLAYING" | true + "FINISHED" | false + "WAITING" | false + null | false + } + + def "isCurrentTurn: 현재 턴 확인"() { + given: + def session = WordChainSession.builder() + .currentPlayerId("player1") + .build() + + expect: + session.isCurrentTurn("player1") == true + session.isCurrentTurn("player2") == false + session.isCurrentTurn(null) == false + } + + def "isWordUsed: 단어 사용 여부 확인"() { + given: + def session = WordChainSession.builder() + .usedWords(["apple", "elephant", "tiger"]) + .build() + + expect: + session.isWordUsed("apple") == true + session.isWordUsed("APPLE") == true + session.isWordUsed("banana") == false + } + + def "isWordUsed: usedWords가 null인 경우"() { + given: + def session = WordChainSession.builder() + .usedWords(null) + .build() + + expect: + session.isWordUsed("apple") == false + } + + def "addUsedWord: 단어 추가"() { + given: + def session = WordChainSession.builder() + .usedWords(new ArrayList<>()) + .wordDefinitions(new HashMap<>()) + .build() + + when: + session.addUsedWord("Apple", "(noun) A fruit") + + then: + session.usedWords.contains("apple") + session.wordDefinitions["apple"] == "(noun) A fruit" + } + + def "addUsedWord: null 리스트에서 시작"() { + given: + def session = WordChainSession.builder() + .usedWords(null) + .wordDefinitions(null) + .build() + + when: + session.addUsedWord("apple", "(noun) A fruit") + + then: + session.usedWords == ["apple"] + session.wordDefinitions["apple"] == "(noun) A fruit" + } + + def "addUsedWord: definition이 null인 경우"() { + given: + def session = WordChainSession.builder() + .usedWords(new ArrayList<>()) + .wordDefinitions(new HashMap<>()) + .build() + + when: + session.addUsedWord("apple", null) + + then: + session.usedWords.contains("apple") + !session.wordDefinitions.containsKey("apple") + } + + def "eliminatePlayer: 플레이어 탈락 처리"() { + given: + def session = WordChainSession.builder() + .activePlayers(new ArrayList<>(["player1", "player2", "player3"])) + .eliminatedPlayers(new ArrayList<>()) + .build() + + when: + session.eliminatePlayer("player2") + + then: + session.activePlayers == ["player1", "player3"] + session.eliminatedPlayers == ["player2"] + } + + def "eliminatePlayer: 이미 탈락한 플레이어는 중복 추가되지 않음"() { + given: + def session = WordChainSession.builder() + .activePlayers(new ArrayList<>(["player1"])) + .eliminatedPlayers(new ArrayList<>(["player2"])) + .build() + + when: + session.eliminatePlayer("player2") + + then: + session.eliminatedPlayers.size() == 1 + } + + def "getNextPlayerId: 다음 플레이어 반환"() { + given: + def session = WordChainSession.builder() + .activePlayers(["player1", "player2", "player3"]) + .currentPlayerId(currentPlayer) + .build() + + expect: + session.getNextPlayerId() == expected + + where: + currentPlayer | expected + "player1" | "player2" + "player2" | "player3" + "player3" | "player1" + null | "player1" + "unknown" | "player1" + } + + def "getNextPlayerId: 한 명만 남은 경우"() { + given: + def session = WordChainSession.builder() + .activePlayers(["winner"]) + .currentPlayerId("winner") + .build() + + expect: + session.getNextPlayerId() == "winner" + } + + def "getNextPlayerId: 빈 리스트인 경우"() { + given: + def session = WordChainSession.builder() + .activePlayers([]) + .build() + + expect: + session.getNextPlayerId() == null + } + + def "addScore: 점수 추가"() { + given: + def session = WordChainSession.builder() + .scores(new HashMap<>()) + .build() + + when: + session.addScore("player1", 10) + session.addScore("player1", 15) + session.addScore("player2", 20) + + then: + session.scores["player1"] == 25 + session.scores["player2"] == 20 + } + + def "addScore: scores가 null인 경우"() { + given: + def session = WordChainSession.builder() + .scores(null) + .build() + + when: + session.addScore("player1", 10) + + then: + session.scores["player1"] == 10 + } + + def "isGameOver: 게임 종료 조건 확인"() { + given: + def session = WordChainSession.builder() + .activePlayers(players) + .build() + + expect: + session.isGameOver() == expected + + where: + players | expected + null | true + [] | true + ["player1"] | true + ["player1", "player2"] | false + } + + def "getWinner: 승자 반환"() { + given: + def session = WordChainSession.builder() + .activePlayers(players) + .build() + + expect: + session.getWinner() == expected + + where: + players | expected + ["winner"] | "winner" + ["p1", "p2"] | null + [] | null + null | null + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy new file mode 100644 index 00000000..d32e825a --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/service/DictionaryServiceSpec.groovy @@ -0,0 +1,54 @@ +package com.mzc.secondproject.serverless.domain.chatting.service + +import spock.lang.Specification + +class DictionaryServiceSpec extends Specification { + + def "DictionaryResult.valid: 유효한 결과 생성"() { + when: + def result = DictionaryService.DictionaryResult.valid("apple", "(noun) A fruit", "/ˈæpəl/") + + then: + result.isValid() + result.word() == "apple" + result.getDefinition().isPresent() + result.getDefinition().get() == "(noun) A fruit" + result.getPhonetic().isPresent() + result.getPhonetic().get() == "/ˈæpəl/" + result.errorMessage() == null + } + + def "DictionaryResult.validWithoutDefinition: 정의 없이 유효한 결과"() { + when: + def result = DictionaryService.DictionaryResult.validWithoutDefinition("apple") + + then: + result.isValid() + result.word() == "apple" + result.getDefinition().isEmpty() + result.getPhonetic().isEmpty() + } + + def "DictionaryResult.invalid: 유효하지 않은 결과"() { + when: + def result = DictionaryService.DictionaryResult.invalid("사전에 없는 단어입니다.") + + then: + !result.isValid() + result.word() == null + result.getDefinition().isEmpty() + result.errorMessage() == "사전에 없는 단어입니다." + } + + def "DictionaryResult.getDefinition: Optional 반환"() { + expect: + DictionaryService.DictionaryResult.valid("test", "def", null).getDefinition().isPresent() + DictionaryService.DictionaryResult.valid("test", null, null).getDefinition().isEmpty() + } + + def "DictionaryResult.getPhonetic: Optional 반환"() { + expect: + DictionaryService.DictionaryResult.valid("test", null, "/test/").getPhonetic().isPresent() + DictionaryService.DictionaryResult.valid("test", null, null).getPhonetic().isEmpty() + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index 3fa504b5..7553d2a5 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -511,6 +511,71 @@ Resources: Auth: Authorizer: CognitoAuthorizer + # 끝말잇기(Word Chain) 게임 핸들러 + WordChainFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-wordchain-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.chatting.handler.WordChainHandler::handleRequest + Description: Handle word chain game operations + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable + - DynamoDBReadPolicy: + TableName: !Ref UserTable + - Statement: + - Effect: Allow + Action: + - execute-api:ManageConnections + Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*" + Events: + StartWordChain: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/start + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SubmitWord: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/submit + Method: POST + Auth: + Authorizer: CognitoAuthorizer + HandleTimeout: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/timeout + Method: POST + Auth: + Authorizer: CognitoAuthorizer + StopWordChain: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/stop + Method: POST + Auth: + Authorizer: CognitoAuthorizer + GetWordChainStatus: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /chat/rooms/{roomId}/wordchain/status + Method: GET + Auth: + Authorizer: CognitoAuthorizer + # 게임 자동 종료 Lambda (EventBridge Scheduler에 의해 호출) GameAutoCloseFunction: Type: AWS::Serverless::Function