Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6f3b0e7
:sparkles: feat : 게임 종료 로직 구현
silver-eunjoo Jul 22, 2025
a24dab5
chore: Java 스타일 수정
Jul 22, 2025
a735651
:sparkles: feat: 게임 종료 후 disconnected 참여자 정리
silver-eunjoo Jul 22, 2025
343e50b
:wrench: chore : 깃 충돌 해결
silver-eunjoo Jul 22, 2025
c4db37d
chore: Java 스타일 수정
Jul 22, 2025
b804952
:recycle: refactor : 게임 시작 시, 실시간 랭킹도 함께 SEND
silver-eunjoo Jul 22, 2025
ba9ca4d
:recycle: refactor : 서비스 간의 순환참조 해결
silver-eunjoo Jul 25, 2025
0670bd5
chore: Java 스타일 수정
Jul 25, 2025
babfe49
:recycle: refactor : 게임 종료 시 로직 변경
silver-eunjoo Jul 25, 2025
939fee3
chore: Java 스타일 수정
Jul 25, 2025
826e6d5
:wrench: chore: 게임 종료 시 모든 메세지 브로드캐스팅하도록 변경
silver-eunjoo Jul 25, 2025
c7beb6a
chore: Java 스타일 수정
Jul 25, 2025
b63cfb0
:bug: fix: index 예외, Lazy 예외 버그 수정
silver-eunjoo Jul 25, 2025
8cc3948
chore: Java 스타일 수정
Jul 25, 2025
9d47481
:sparkles: [feat] 유저 닉네임으로 정보 조회 (#101)
jiwon1217 Jul 21, 2025
2795ad1
♻️ refactor: 세션 타임아웃 값을 환경변수로 대치 (#105)
dlsrks1021 Jul 22, 2025
6e6dcd3
:wrench: chore : 깃 충돌 해결
silver-eunjoo Jul 25, 2025
77031fb
:wrench: chore : 깃 충돌 해결
silver-eunjoo Jul 25, 2025
e3beace
:recycle: refactor: 랭킹 조회 Redis 적용 (#111)
dlsrks1021 Jul 24, 2025
931a184
✨ feat: GameSetting 변경 기능 추가 (#107)
LimKangHyun Jul 24, 2025
6fcd83c
🐛 fix: 라운드 변경 시 question개수 조회에 fetch join 적용 (#116)
LimKangHyun Jul 25, 2025
f2388ef
chore: Java 스타일 수정
Jul 25, 2025
4a303e9
Merge branch 'dev' into feat/103
silver-eunjoo Jul 25, 2025
916f8c4
chore: Java 스타일 수정
Jul 25, 2025
3846701
Merge branch 'dev' into feat/103
silver-eunjoo Jul 25, 2025
4ac146b
:wrench: chore : 테스트 코드 통과
silver-eunjoo Jul 25, 2025
6bdfd15
chore: Java 스타일 수정
Jul 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.f1.backend.domain.game.app;

import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination;

import io.f1.backend.domain.game.dto.ChatMessage;
import io.f1.backend.domain.game.dto.MessageType;
import io.f1.backend.domain.game.event.GameCorrectAnswerEvent;
import io.f1.backend.domain.game.model.Room;
import io.f1.backend.domain.game.websocket.MessageSender;
import io.f1.backend.domain.question.entity.Question;

import lombok.RequiredArgsConstructor;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class ChatService {

private final RoomService roomService;
private final TimerService timerService;
private final MessageSender messageSender;
private final ApplicationEventPublisher eventPublisher;

// todo 동시성적용
public void chat(Long roomId, String sessionId, ChatMessage chatMessage) {

Room room = roomService.findRoom(roomId);

String destination = getDestination(roomId);

messageSender.send(destination, MessageType.CHAT, chatMessage);

if (!room.isPlaying()) {
return;
}

Question currentQuestion = room.getCurrentQuestion();

String answer = currentQuestion.getAnswer();

if (answer.equals(chatMessage.message())) {
Copy link
Collaborator

@sehee123 sehee123 Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

동시성 처리가 redis로 변환될 예정이니 chat을 서비스로 분리한 점 넘 좋네욥!

정답일 경우 구현해놓은 로직은 eventPublisher로 구현하신 이유가 있을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 위에 리뷰 확인했습니다 !

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서비스 간의 순환 참조가 발생하면서, 게임 진행의 과정이 다른 서비스와 너무 연결되어 사용된다고 생각했습니다.

기존 코드 기준 예를 들어, 채팅 (정답) (RoomService)-> 타이머 취소 (TimerService) -> 마지막 라운드 시 게임 종료 (GameService) -> Disconnected 된 플레이어 처리 (RoomService) 이런 식으로 서비스들이 서로 깊게 연결되고, 흐름 제어가 분산되어 있어서 유지보수와 테스트가 어렵다고 생각했습니다.

그래서 정답 맞힘/타임아웃과 같은 주요 게임 이벤트를 ApplicationEventPublisher를 통해 GameService에서 받아서 처리하도록 변경했습니다. 정답을 맞혔을 때, 타임아웃이 됐을 때, 두 개의 상황에서 GameService에서 서로 연결되게 호출하는 방식이 아닌 이벤트 핸들링을 통해 GameService에서 핸들링해줄 수 있도록 하기 위해 이벤트 퍼블리싱 방식으로 구현했습니다 !

eventPublisher.publishEvent(
new GameCorrectAnswerEvent(room, sessionId, chatMessage, answer));
}
}
}
121 changes: 111 additions & 10 deletions backend/src/main/java/io/f1/backend/domain/game/app/GameService.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package io.f1.backend.domain.game.app;

import static io.f1.backend.domain.game.mapper.RoomMapper.toGameSettingResponse;
import static io.f1.backend.domain.game.mapper.RoomMapper.toPlayerListResponse;
import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionStartResponse;
import static io.f1.backend.domain.game.mapper.RoomMapper.*;
import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination;
import static io.f1.backend.domain.quiz.mapper.QuizMapper.toGameStartResponse;

import io.f1.backend.domain.game.dto.ChatMessage;
import io.f1.backend.domain.game.dto.MessageType;
import io.f1.backend.domain.game.dto.RoomEventType;
import io.f1.backend.domain.game.dto.request.GameSettingChanger;
import io.f1.backend.domain.game.dto.response.PlayerListResponse;
import io.f1.backend.domain.game.event.GameCorrectAnswerEvent;
import io.f1.backend.domain.game.event.GameTimeoutEvent;
import io.f1.backend.domain.game.event.RoomUpdatedEvent;
import io.f1.backend.domain.game.model.Player;
import io.f1.backend.domain.game.model.Room;
Expand All @@ -26,21 +29,26 @@
import lombok.extern.slf4j.Slf4j;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.Objects;

@Slf4j
@Service
@RequiredArgsConstructor
public class GameService {

public static final int START_DELAY = 5;
private static final int START_DELAY = 5;
private static final int CONTINUE_DELAY = 3;
private static final String NONE_CORRECT_USER = "";

private final MessageSender messageSender;
private final TimerService timerService;
private final QuizService quizService;
private final RoomService roomService;
private final TimerService timerService;
private final MessageSender messageSender;
private final RoomRepository roomRepository;
private final ApplicationEventPublisher eventPublisher;

Expand Down Expand Up @@ -68,12 +76,109 @@ public void gameStart(Long roomId, UserPrincipal principal) {
timerService.startTimer(room, START_DELAY);

messageSender.send(destination, MessageType.GAME_START, toGameStartResponse(questions));
messageSender.send(destination, MessageType.RANK_UPDATE, toRankUpdateResponse(room));
messageSender.send(
destination,
MessageType.QUESTION_START,
toQuestionStartResponse(room, START_DELAY));
}

@EventListener
public void onCorrectAnswer(GameCorrectAnswerEvent event) {

Room room = event.room();
String sessionId = event.sessionId();
ChatMessage chatMessage = event.chatMessage();
String answer = event.answer();

String destination = getDestination(room.getId());

room.increasePlayerCorrectCount(sessionId);

messageSender.send(
destination,
MessageType.QUESTION_RESULT,
toQuestionResultResponse(chatMessage.nickname(), answer));
messageSender.send(destination, MessageType.RANK_UPDATE, toRankUpdateResponse(room));
messageSender.send(
destination,
MessageType.SYSTEM_NOTICE,
ofPlayerEvent(chatMessage.nickname(), RoomEventType.CORRECT_ANSWER));

timerService.cancelTimer(room);

if (!timerService.validateCurrentRound(room)) {
gameEnd(room);
return;
}

room.increaseCurrentRound();

// 타이머 추가하기
timerService.startTimer(room, CONTINUE_DELAY);
messageSender.send(
destination,
MessageType.QUESTION_START,
toQuestionStartResponse(room, CONTINUE_DELAY));
}

@EventListener
public void onTimeout(GameTimeoutEvent event) {
Room room = event.room();
String destination = getDestination(room.getId());

messageSender.send(
destination,
MessageType.QUESTION_RESULT,
toQuestionResultResponse(NONE_CORRECT_USER, room.getCurrentQuestion().getAnswer()));
messageSender.send(
destination,
MessageType.SYSTEM_NOTICE,
ofPlayerEvent(NONE_CORRECT_USER, RoomEventType.TIMEOUT));

if (!timerService.validateCurrentRound(room)) {
gameEnd(room);
return;
}

room.increaseCurrentRound();

timerService.startTimer(room, CONTINUE_DELAY);
messageSender.send(
destination,
MessageType.QUESTION_START,
toQuestionStartResponse(room, CONTINUE_DELAY));
}

public void gameEnd(Room room) {
Long roomId = room.getId();
String destination = getDestination(roomId);

Map<String, Player> playerSessionMap = room.getPlayerSessionMap();

// TODO : 랭킹 정보 업데이트
messageSender.send(
destination,
MessageType.GAME_RESULT,
toGameResultListResponse(playerSessionMap, room.getGameSetting().getRound()));

room.initializeRound();
room.initializePlayers();

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disconnect 플레이어들은 continue;를 추가해서
아래initializeCorrectCount(); toglleReady(); 를 실행 안하게 만들어도 되지 않을까요 ?.?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

room 내부 로직으로 변경하면서, filter()로 disconnectedPlayers에 해당 플레이어가 속해있는지 판단하고 레디 여부 등을 실행하지 않게 구현할 수도 있었지만, contains()를 두고 비교하는 과정이 한 번 더 들어가야 한다는 점이 복잡도에서 큰 이점이 있다고 생각되지 않아 먼저 상태를 다 init 상태로 돌려두고 disconnectedPlayers들을 처리하는 방식으로 구현했습니다 !

List<Player> disconnectedPlayers = room.getDisconnectedPlayers();
roomService.handleDisconnectedPlayers(room, disconnectedPlayers);

room.updateRoomState(RoomState.WAITING);

messageSender.send(
destination,
MessageType.GAME_SETTING,
toGameSettingResponse(
room.getGameSetting(),
quizService.getQuizWithQuestionsById(room.getGameSetting().getQuizId())));
messageSender.send(destination, MessageType.ROOM_SETTING, toRoomSettingResponse(room));
}

public void handlePlayerReady(Long roomId, String sessionId) {

Room room = findRoom(roomId);
Expand Down Expand Up @@ -136,10 +241,6 @@ private Room findRoom(Long roomId) {
.orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND));
}

private String getDestination(Long roomId) {
return "/sub/room/" + roomId;
}

private void validateHostAndState(Room room, UserPrincipal principal) {
if (!room.isHost(principal.getUserId())) {
throw new CustomException(RoomErrorCode.NOT_ROOM_OWNER);
Expand Down
Loading