Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,13 +1,16 @@
package io.f1.backend.domain.game.app;

import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionStartResponse;
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.response.GameStartResponse;
import io.f1.backend.domain.game.dto.MessageType;
import io.f1.backend.domain.game.event.RoomUpdatedEvent;
import io.f1.backend.domain.game.model.Player;
import io.f1.backend.domain.game.model.Room;
import io.f1.backend.domain.game.model.RoomState;
import io.f1.backend.domain.game.store.RoomRepository;
import io.f1.backend.domain.game.websocket.MessageSender;
import io.f1.backend.domain.question.entity.Question;
import io.f1.backend.domain.quiz.app.QuizService;
import io.f1.backend.domain.quiz.entity.Quiz;
Expand All @@ -29,11 +32,17 @@
@RequiredArgsConstructor
public class GameService {

private static final int START_DELAY = 5;

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

public GameStartResponse gameStart(Long roomId, UserPrincipal principal) {
public void gameStart(Long roomId, UserPrincipal principal) {

String destination = getDestination(roomId);

Room room =
roomRepository
Expand All @@ -47,13 +56,18 @@ public GameStartResponse gameStart(Long roomId, UserPrincipal principal) {
List<Question> questions = prepareQuestions(room, quiz);

room.updateQuestions(questions);

// 방 정보 게임 중으로 변경
room.increaseCurrentRound();
room.updateRoomState(RoomState.PLAYING);

eventPublisher.publishEvent(new RoomUpdatedEvent(room, quiz));

return toGameStartResponse(questions);
timerService.startTimer(room, START_DELAY);

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

private boolean validateReadyStatus(Room room) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
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.toQuestionResultResponse;
import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionStartResponse;
import static io.f1.backend.domain.game.mapper.RoomMapper.toRankUpdateResponse;
import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomResponse;
import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomSetting;
import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomSettingResponse;
import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination;
import static io.f1.backend.global.util.SecurityUtils.getCurrentUserId;
import static io.f1.backend.global.util.SecurityUtils.getCurrentUserNickname;

Expand Down Expand Up @@ -57,12 +59,17 @@
@RequiredArgsConstructor
public class RoomService {

private final TimerService timerService;
private final QuizService quizService;
private final RoomRepository roomRepository;
private final AtomicLong roomIdGenerator = new AtomicLong(0);
private final ApplicationEventPublisher eventPublisher;
private final Map<Long, Object> roomLocks = new ConcurrentHashMap<>();

private final MessageSender messageSender;

private static final int CONTINUE_DELAY = 3;

private static final String PENDING_SESSION_ID = "PENDING_SESSION_ID";

public RoomCreateResponse saveRoom(RoomCreateRequest request) {
Expand Down Expand Up @@ -226,6 +233,7 @@ public RoomListResponse getAllRooms() {

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

Room room = findRoom(roomId);

String destination = getDestination(roomId);
Expand All @@ -246,12 +254,29 @@ public void chat(Long roomId, String sessionId, ChatMessage chatMessage) {
messageSender.send(
destination,
MessageType.QUESTION_RESULT,
toQuestionResultResponse(currentQuestion.getId(), chatMessage, answer));
toQuestionResultResponse(chatMessage.nickname(), answer));
messageSender.send(destination, MessageType.RANK_UPDATE, toRankUpdateResponse(room));
messageSender.send(
destination,
MessageType.SYSTEM_NOTICE,
ofPlayerEvent(chatMessage.nickname(), RoomEventType.ENTER));
ofPlayerEvent(chatMessage.nickname(), RoomEventType.CORRECT_ANSWER));

timerService.cancelTimer(room);
Copy link
Collaborator

Choose a reason for hiding this comment

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

[L2-변경협의]
cancelTimer 직전 시점에 timeout으로 인해 handleTimeout이 호출되는 edge case가 존재할 것 같습니다.
이 경우를 대비해 cancelTimer 메서드에 timer가 이미 정지된 상태인지 알려주는 boolean return 값을 부여하고,
이를 통해 조건문 처리를 하면 좋을 것 같습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

cancelTimer() 호출 시점을 chat() 메서드 초반에 호출하는 방식으로 리팩토링해보겠습니다.

근데, timer가 이미 정지된 상태인지 알려주는 boolean return 값이 왜 필요한지 잘 모르겠는데, 혹시 더 설명해주실 수 있으실까요?

Copy link
Collaborator

@dlsrks1021 dlsrks1021 Jul 21, 2025

Choose a reason for hiding this comment

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

cancelTimer 호출 시 timer가 정지 상태라는 것은 handleTimeout이 먼저 호출됐다는 뜻일 겁니다.
그렇다면 handleTimeout 로직에 따라 Question Result와 System Notice가 브로드캐스트 된 이후 새로운 timer의 start와 Question Start를 진행하는 과정을 거치고 있을 텐데, chat 메서드 내의 cancelTimer 호출 이후 같은 로직을 진행하면 오동작을 일으킬 가능성이 존재할 것 같습니다.
따라서 boolean return 값을 통해 분기 처리를 해서 같은 로직을 진행하지 않도록 하는게 좋을 것 같다는 의견이었습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

오.. 그렇군요 ! 이해했습니다. 리뷰를 읽고 생각을 해봤는데,, 채팅으로 정답을 맞혔을 때, 요청을 처리하는 도중 타임아웃이 된 경우를 생각하다보니,, 동시성까지 생각해야 해서 생각이 많아졌습니다..! 혹시 이 부분은 좀 더 생각해보고 다음 PR에 반영하도록 하겠습니다 !!! 감사합니다 ! :)


// TODO : 게임 종료 로직 추가
if (!timerService.validateCurrentRound(room)) {
// 게임 종료 로직
return;
}

room.increaseCurrentRound();

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

Expand Down Expand Up @@ -311,8 +336,4 @@ private void removePlayer(Room room, String sessionId, Player removePlayer) {
room.removeUserId(removePlayer.getId());
room.removeSessionId(sessionId);
}

private String getDestination(Long roomId) {
return "/sub/room/" + roomId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.f1.backend.domain.game.app;

import static io.f1.backend.domain.game.mapper.RoomMapper.ofPlayerEvent;
import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionResultResponse;
import static io.f1.backend.domain.game.mapper.RoomMapper.toQuestionStartResponse;
import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination;

import io.f1.backend.domain.game.dto.MessageType;
import io.f1.backend.domain.game.dto.RoomEventType;
import io.f1.backend.domain.game.model.Room;
import io.f1.backend.domain.game.websocket.MessageSender;

import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;

import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class TimerService {

private final MessageSender messageSender;

private static final String NONE_CORRECT_USER = "";
private static final int CONTINUE_DELAY = 3;

public void startTimer(Room room, int delaySec) {
cancelTimer(room);

ScheduledFuture<?> timer =
room.getScheduler()
.schedule(
() -> {
handleTimeout(room);
},
delaySec + room.getGameSetting().getTimeLimit(),
TimeUnit.SECONDS);
Copy link
Collaborator

Choose a reason for hiding this comment

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

오홍.. 재연결 유예시간 로직 구현할 때 참고해서 구현해봐야겠군요 👀


room.updateTimer(timer);
}

private void handleTimeout(Room 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));

// TODO : 게임 종료 로직
if (!validateCurrentRound(room)) {
// 게임 종료 로직
// GAME_SETTING, PLAYER_LIST, GAME_RESULT, ROOM_SETTING
return;
}

// 다음 문제 출제
room.increaseCurrentRound();

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

public boolean validateCurrentRound(Room room) {
if (room.getGameSetting().getRound() != room.getCurrentRound()) {
return true;
}
cancelTimer(room);
room.getScheduler().shutdown();
return false;
}

public boolean cancelTimer(Room room) {
// 정답 맞혔어요 ~ 타이머 캔슬 부탁
ScheduledFuture<?> timer = room.getTimer();
if (timer != null && !timer.isDone()) {
return timer.cancel(false);
}
return false;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

여기도 있군요.
확실히 Util로 분리해야할것 같습니다.. 🥕

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.f1.backend.domain.game.dto;

public enum GameEventType {
START,
CONTINUE
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ public enum MessageType {
CHAT,
QUESTION_RESULT,
RANK_UPDATE,
QUESTION_START
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
package io.f1.backend.domain.game.dto;

public enum RoomEventType {
ENTER,
EXIT,
START,
END,
ENTER(SystemNoticeMessage.ENTER),
EXIT(SystemNoticeMessage.EXIT),
START(null),
END(null),
CORRECT_ANSWER(SystemNoticeMessage.CORRECT_ANSWER),
TIMEOUT(SystemNoticeMessage.TIMEOUT);

private final SystemNoticeMessage systemMessage;

RoomEventType(SystemNoticeMessage systemMessage) {
this.systemMessage = systemMessage;
}

public String getMessage(String nickname) {

if (this == TIMEOUT) {
return systemMessage.getMessage();
}

return nickname + systemMessage.getMessage();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.f1.backend.domain.game.dto;

public enum SystemNoticeMessage {
ENTER(" 님이 입장하셨습니다"),
EXIT(" 님이 퇴장하셨습니다"),
CORRECT_ANSWER(" 님 정답입니다 !"),
TIMEOUT("땡 ~ ⏰ 제한 시간 초과!");

private final String message;

SystemNoticeMessage(String message) {
this.message = message;
}

public String getMessage() {
return message;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package io.f1.backend.domain.game.dto.response;

public record QuestionResultResponse(Long questionId, String correctUser, String answer) {}
public record QuestionResultResponse(String correctUser, String answer) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.f1.backend.domain.game.dto.response;

import java.time.Instant;

public record QuestionStartResponse(Long questionId, int round, Instant timestamp) {}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package io.f1.backend.domain.game.mapper;

import io.f1.backend.domain.game.dto.ChatMessage;
import io.f1.backend.domain.game.dto.Rank;
import io.f1.backend.domain.game.dto.RoomEventType;
import io.f1.backend.domain.game.dto.request.RoomCreateRequest;
import io.f1.backend.domain.game.dto.response.GameSettingResponse;
import io.f1.backend.domain.game.dto.response.PlayerListResponse;
import io.f1.backend.domain.game.dto.response.PlayerResponse;
import io.f1.backend.domain.game.dto.response.QuestionResultResponse;
import io.f1.backend.domain.game.dto.response.QuestionStartResponse;
import io.f1.backend.domain.game.dto.response.QuizResponse;
import io.f1.backend.domain.game.dto.response.RankUpdateResponse;
import io.f1.backend.domain.game.dto.response.RoomResponse;
Expand Down Expand Up @@ -87,18 +87,12 @@ public static QuizResponse toQuizResponse(Quiz quiz) {
}

public static SystemNoticeResponse ofPlayerEvent(String nickname, RoomEventType roomEventType) {
String message = "";
if (roomEventType == RoomEventType.ENTER) {
message = " 님이 입장하셨습니다";
} else if (roomEventType == RoomEventType.EXIT) {
message = " 님이 퇴장하셨습니다";
}
return new SystemNoticeResponse(nickname + message, Instant.now());
return new SystemNoticeResponse(roomEventType.getMessage(nickname), Instant.now());
}

public static QuestionResultResponse toQuestionResultResponse(
Long questionId, ChatMessage chatMessage, String answer) {
return new QuestionResultResponse(questionId, chatMessage.nickname(), answer);
String correctUser, String answer) {
return new QuestionResultResponse(correctUser, answer);
}

public static RankUpdateResponse toRankUpdateResponse(Room room) {
Expand All @@ -108,4 +102,11 @@ public static RankUpdateResponse toRankUpdateResponse(Room room) {
.map(player -> new Rank(player.getNickname(), player.getCorrectCount()))
.toList());
}

public static QuestionStartResponse toQuestionStartResponse(Room room, int delay) {
return new QuestionStartResponse(
room.getCurrentQuestion().getId(),
room.getCurrentRound(),
Instant.now().plusSeconds(delay));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class GameSetting {

private Long quizId;
private Integer round; // 게임 변경 시 해당 게임의 총 문제 수로 설정
private int timeLimit = 60;
Copy link
Collaborator

Choose a reason for hiding this comment

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

이부분 전 캐치 못하고있었는뎁 감사합니다 ~!

private int timeLimit;

public boolean validateQuizId(Long quizId) {
return Objects.equals(this.quizId, quizId);
Expand Down
Loading