Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
be3f034
:sparkles: feat: 게임 설정 변경 인터페이스 및 구현체 추가
LimKangHyun Jul 22, 2025
e1d79fc
chore: Java 스타일 수정
Jul 22, 2025
214bc54
:sparkles: feat: 게임 설정 변경 기능 추가
LimKangHyun Jul 22, 2025
ccac167
chore: Java 스타일 수정
Jul 22, 2025
9af131a
:truck: move: 소켓 요청 DTO request 디렉토리로 이동
LimKangHyun Jul 22, 2025
2fb0b8f
:truck: move: 소켓 요청 DTO 경로 변경
LimKangHyun Jul 22, 2025
a7ead79
chore: Java 스타일 수정
Jul 22, 2025
6981459
:recycle: refactor: 게임 세팅 요청 후처리 분리
LimKangHyun Jul 22, 2025
fdaaee7
chore: Java 스타일 수정
Jul 22, 2025
655929e
:recycle: refactor: 코드 리뷰 반영
LimKangHyun Jul 23, 2025
39d9f54
chore: Java 스타일 수정
Jul 23, 2025
80101d0
:wastebasket: remove: 불필요 코드 삭제
LimKangHyun Jul 23, 2025
db62cbc
chore: Java 스타일 수정
Jul 23, 2025
181f4ab
:recycle: refactor: isHost 원상복구
LimKangHyun Jul 23, 2025
c7506d9
:recycle: refactor: QuizChangeRequest, RoundChangeRequest 타입 수정
LimKangHyun Jul 23, 2025
b6ae413
:wrench: chore: RoomUpdatedEventListener, RoomDeletedEventListener 빈 등록
LimKangHyun Jul 23, 2025
476ceea
chore: Java 스타일 수정
Jul 23, 2025
b7852c2
:wrench: fix: 충돌 해결
LimKangHyun Jul 23, 2025
2db2e95
Merge remote-tracking branch 'origin/feat/100' into feat/100
LimKangHyun Jul 23, 2025
7c6515e
chore: Java 스타일 수정
Jul 23, 2025
482bc72
:recycle: refactor: player 조회 메서드 추가
LimKangHyun Jul 24, 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
@@ -1,10 +1,12 @@
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.websocket.WebSocketUtils.getDestination;
import static io.f1.backend.domain.quiz.mapper.QuizMapper.toGameStartResponse;

import io.f1.backend.domain.game.dto.MessageType;
import io.f1.backend.domain.game.dto.request.GameSettingChanger;
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 @@ -25,7 +27,6 @@
import org.springframework.stereotype.Service;

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

@Service
Expand Down Expand Up @@ -70,19 +71,47 @@ public void gameStart(Long roomId, UserPrincipal principal) {
toQuestionStartResponse(room, START_DELAY));
}

private boolean validateReadyStatus(Room room) {
public void handlePlayerReady(Long roomId, String sessionId) {
Player player =
roomRepository
.findPlayerInRoomBySessionId(roomId, sessionId)
Copy link
Collaborator

@sehee123 sehee123 Jul 22, 2025

Choose a reason for hiding this comment

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

이렇게 player를 가져오는 코드가 있는줄은 몰랐네요!

단순하게 room.PlayerSessionMap.get(sessionId) 를 쓰지 않고 이렇게 가져오는 이유가 있을까요?
NPE 때문일까요?

Copy link
Collaborator Author

@LimKangHyun LimKangHyun Jul 23, 2025

Choose a reason for hiding this comment

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

저건 작성한지 좀 된것같은데, 세션아이디 재연결 로직이 없어서 저렇게 짠것 같습니다. 재연결 로직에서 맵에서 바로 세션을 바로 삭제하지 않으니까 Room안에서 플레이어는 null일 가능성이 없을것 같네요!
소켓의 세션아이디가 널이 아니어도 룸에 플레이어로 등록되지 않은 경우도 존재할 것 같네요! null 체크하나 추가해서 수정했습니다!
수정했습니다!

.orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND));

Room room = findRoom(roomId);

toggleReadyIfPossible(room, player);

String destination = getDestination(roomId);

messageSender.send(destination, MessageType.PLAYER_LIST, toPlayerListResponse(room));
}

public void changeGameSetting(
Long roomId, UserPrincipal principal, GameSettingChanger request) {
Room room = findRoom(roomId);
validateHostAndState(room, principal);

Map<String, Player> playerSessionMap = room.getPlayerSessionMap();
if (!request.change(room, quizService)) {
return;
}
request.afterChange(room, messageSender);

broadcastGameSetting(room);

return playerSessionMap.values().stream().allMatch(Player::isReady);
RoomUpdatedEvent roomUpdatedEvent =
new RoomUpdatedEvent(
room,
quizService.getQuizWithQuestionsById(room.getGameSetting().getQuizId()));

eventPublisher.publishEvent(roomUpdatedEvent);
}

private void validateRoomStart(Room room, UserPrincipal principal) {
if (!Objects.equals(principal.getUserId(), room.getHost().getId())) {
throw new CustomException(RoomErrorCode.NOT_ROOM_OWNER);
}

if (!validateReadyStatus(room)) {
if (!room.validateReadyStatus()) {
throw new CustomException(GameErrorCode.PLAYER_NOT_READY);
}

Expand All @@ -97,4 +126,41 @@ private List<Question> prepareQuestions(Room room, Quiz quiz) {
Integer round = room.getGameSetting().getRound();
return quizService.getRandomQuestionsWithoutAnswer(quizId, round);
}

private Room findRoom(Long roomId) {
return roomRepository
.findRoom(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 (!Objects.equals(principal.getUserId(), room.getHost().getId())) {
throw new CustomException(RoomErrorCode.NOT_ROOM_OWNER);
}
if (room.isPlaying()) {
throw new CustomException(RoomErrorCode.GAME_ALREADY_PLAYING);
}
}

private void toggleReadyIfPossible(Room room, Player player) {
if (room.isPlaying()) {
throw new CustomException(RoomErrorCode.GAME_ALREADY_PLAYING);
}
if (!Objects.equals(player.getId(), room.getHost().getId())) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

오홍 이것도 NPE 를 방지하고자 Objects.equals를 쓰신건가요?

Copy link
Collaborator Author

@LimKangHyun LimKangHyun Jul 23, 2025

Choose a reason for hiding this comment

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

Host에 대해서는 널 체크를 하지 않아도 될것 같습니다! isHost로 변경했습니다!

player.toggleReady();
}
}

private void broadcastGameSetting(Room room) {
String destination = getDestination(room.getId());
Quiz quiz = quizService.getQuizWithQuestionsById(room.getGameSetting().getQuizId());
messageSender.send(
destination,
MessageType.GAME_SETTING,
toGameSettingResponse(room.getGameSetting(), quiz));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -201,21 +201,6 @@ public void exitRoom(Long roomId, String sessionId, UserPrincipal principal) {
}
}

public void handlePlayerReady(Long roomId, String sessionId) {
Player player =
roomRepository
.findPlayerInRoomBySessionId(roomId, sessionId)
.orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND));

player.toggleReady();

Room room = findRoom(roomId);

String destination = getDestination(roomId);

messageSender.send(destination, MessageType.PLAYER_LIST, toPlayerListResponse(room));
}

public RoomListResponse getAllRooms() {
List<Room> rooms = roomRepository.findAll();
List<RoomResponse> roomResponses =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.f1.backend.domain.game.dto.request;

import io.f1.backend.domain.game.model.Room;
import io.f1.backend.domain.game.websocket.MessageSender;
import io.f1.backend.domain.quiz.app.QuizService;

public interface GameSettingChanger {

boolean change(Room room, QuizService quizService);

void afterChange(Room room, MessageSender messageSender);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.f1.backend.domain.game.dto.request;

import static io.f1.backend.domain.game.mapper.RoomMapper.toPlayerListResponse;

import io.f1.backend.domain.game.dto.MessageType;
import io.f1.backend.domain.game.dto.response.PlayerListResponse;
import io.f1.backend.domain.game.model.Room;
import io.f1.backend.domain.game.websocket.MessageSender;
import io.f1.backend.domain.quiz.app.QuizService;
import io.f1.backend.domain.quiz.entity.Quiz;

import java.util.Objects;

public record QuizChangeRequest(Long quizId) implements GameSettingChanger {

@Override
public boolean change(Room room, QuizService quizService) {
if (Objects.equals(room.getGameSetting().getQuizId(), quizId)) {
return false; // 동일하면 무시
}
Quiz quiz = quizService.getQuizWithQuestionsById(quizId);
int questionSize = quiz.getQuestions().size();
room.getGameSetting().changeQuiz(quiz);
// 퀴즈의 문제 갯수로 변경
room.getGameSetting().changeRound(questionSize, questionSize);
Copy link
Collaborator

@sehee123 sehee123 Jul 22, 2025

Choose a reason for hiding this comment

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

room.getGameSetting().changeQuiz()
room.getGameSetting().changeRound()
Room 객체 안에 하나의 메소드로 만들면 깔끔할 것 같다는 의견입니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

gameSetting없이 Room으로 호출가능하도록 변경했습니다!

return true;
}

@Override
public void afterChange(Room room, MessageSender messageSender) {
room.resetAllPlayerReadyStates();

String destination = "/sub/room/" + room.getId();
Copy link
Collaborator

@sehee123 sehee123 Jul 22, 2025

Choose a reason for hiding this comment

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

WebsocketUtils 에서 getDestination(roomId)로 대체할 수 있을 것 같습니다!

PlayerListResponse response = toPlayerListResponse(room);

messageSender.send(destination, MessageType.PLAYER_LIST, response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.f1.backend.domain.game.dto.request;

import io.f1.backend.domain.game.model.Room;
import io.f1.backend.domain.game.websocket.MessageSender;
import io.f1.backend.domain.quiz.app.QuizService;
import io.f1.backend.domain.quiz.entity.Quiz;

import java.util.Objects;

public record RoundChangeRequest(Integer round) implements GameSettingChanger {

@Override
public boolean change(Room room, QuizService quizService) {
if (Objects.equals(room.getGameSetting().getRound(), round)) {
return false; // 동일하면 무시
}

Quiz quiz = quizService.findQuizById(room.getGameSetting().getQuizId());
int questionSize = quiz.getQuestions().size();

room.getGameSetting().changeRound(round, questionSize);
return true;
}

@Override
public void afterChange(Room room, MessageSender messageSender) {
// 고유한 후처리 동작 없음
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.f1.backend.domain.game.dto.request;

import io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.GameErrorCode;

import lombok.Getter;

import java.util.Arrays;

@Getter
public enum TimeLimit {
FIFTEEN(15),
THIRTY(30),
FORTY_FIVE(45),
SIXTY(60);

private final int value;

TimeLimit(int value) {
this.value = value;
}

public static TimeLimit from(int value) {
return Arrays.stream(values())
.filter(t -> t.value == value)
.findFirst()
.orElseThrow(() -> new CustomException(GameErrorCode.GAME_SETTING_CONFLICT));
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.

게임 내에서 일반 사용자가 유효하지 않은 타이머를 보낼일은 없다고 생각해서, 비정상적인 요청에 대해서 만들어놓은 방어적 예외라 예외 내용을 구체화하지는 않았습니다!

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

import io.f1.backend.domain.game.model.Room;
import io.f1.backend.domain.game.websocket.MessageSender;
import io.f1.backend.domain.quiz.app.QuizService;

public record TimeLimitChangeRequest(int timeLimit) implements GameSettingChanger {

@Override
public boolean change(Room room, QuizService quizService) {
if (room.getGameSetting().getTimeLimit() == timeLimit) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Room 내부 메서드로 위치시키면 좋을 것 같습니다 !

참고링크: https://mangkyu.tistory.com/147

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과 gameSetting의 관계를 생각을 안하고 만들었네요 수정했습니다!
인사이트 감사합니다!

return false; // 동일하면 무시
}
room.getGameSetting().changeTimeLimit(TimeLimit.from(timeLimit));
Copy link
Collaborator

Choose a reason for hiding this comment

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

위의 리뷰 내용과 동일합니다 !

return true;
}

@Override
public void afterChange(Room room, MessageSender messageSender) {
// 고유한 후처리 동작 없음
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public static GameSettingResponse toGameSettingResponse(GameSetting gameSetting,
public static PlayerListResponse toPlayerListResponse(Room room) {
List<PlayerResponse> playerResponseList =
room.getPlayerSessionMap().values().stream()
.map(player -> new PlayerResponse(player.getNickname(), false))
.map(player -> new PlayerResponse(player.getNickname(), player.isReady()))
.toList();

return new PlayerListResponse(room.getHost().getNickname(), playerResponseList);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
package io.f1.backend.domain.game.model;

import io.f1.backend.domain.game.dto.request.TimeLimit;
import io.f1.backend.domain.quiz.entity.Quiz;
import io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.GameErrorCode;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.Objects;

@Getter
@AllArgsConstructor
public class GameSetting {

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

public boolean validateQuizId(Long quizId) {
return Objects.equals(this.quizId, quizId);
public void changeQuiz(Quiz quiz) {
quizId = quiz.getId();
round = quiz.getQuestions().size(); // 라운드를 바꾼 퀴즈의 문제 수로 동기화
}

public void changeTimeLimit(TimeLimit timeLimit) {
this.timeLimit = timeLimit.getValue();
}

public void changeRound(int round, int questionsCount) {
if (round > questionsCount) {
throw new CustomException(GameErrorCode.ROUND_EXCEEDS_QUESTION_COUNT);
}
this.round = round;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public void toggleReady() {
this.isReady = !this.isReady;
}

public void setReadyFalse() {
this.isReady = false;
}

public void increaseCorrectCount() {
correctCount++;
}
Expand Down
13 changes: 13 additions & 0 deletions backend/src/main/java/io/f1/backend/domain/game/model/Room.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
Expand Down Expand Up @@ -90,4 +91,16 @@ public Boolean isPlaying() {
public void increaseCurrentRound() {
currentRound++;
}

public boolean validateReadyStatus() {

return playerSessionMap.values().stream().allMatch(Player::isReady);
}

public void resetAllPlayerReadyStates() {
for (Player player : playerSessionMap.values()) {
if (Objects.equals(player.getId(), getHost().getId())) continue;
Copy link
Collaborator

Choose a reason for hiding this comment

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

isHost() 메서드가 아닌 Objects.equal()를 통해 직접 비교한 이유가 궁금합니다 ! 만약, 이유가 null-safe라면 isHost()를 null-safe하게 바꿔 사용하는 것이 가독성이나 일관성 측면에서 좋을 것 같습니다 !

Copy link
Collaborator Author

@LimKangHyun LimKangHyun Jul 23, 2025

Choose a reason for hiding this comment

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

Host에 대해서는 널 체크를 하지 않아도 될것 같습니다! isHost로 변경했습니다!

player.setReadyFalse();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import io.f1.backend.domain.game.app.GameService;
import io.f1.backend.domain.game.app.RoomService;
import io.f1.backend.domain.game.dto.ChatMessage;
import io.f1.backend.domain.game.dto.QuizChangeRequest;
import io.f1.backend.domain.game.dto.RoundChangeRequest;
import io.f1.backend.domain.game.dto.TimeLimitChangeRequest;
import io.f1.backend.domain.game.dto.request.DefaultWebSocketRequest;
import io.f1.backend.domain.user.dto.UserPrincipal;

Expand Down Expand Up @@ -61,6 +64,30 @@ public void chat(
@MessageMapping("/room/ready/{roomId}")
public void playerReady(@DestinationVariable Long roomId, Message<?> message) {

roomService.handlePlayerReady(roomId, getSessionId(message));
gameService.handlePlayerReady(roomId, getSessionId(message));
}

@MessageMapping("/room/quiz/{roomId}")
public void quizChange(
@DestinationVariable Long roomId,
Message<DefaultWebSocketRequest<QuizChangeRequest>> message) {
UserPrincipal principal = getSessionUser(message);
gameService.changeGameSetting(roomId, principal, message.getPayload().getMessage());
}

@MessageMapping("/room/time-limit/{roomId}")
public void timeLimitChange(
@DestinationVariable Long roomId,
Message<DefaultWebSocketRequest<TimeLimitChangeRequest>> message) {
UserPrincipal principal = getSessionUser(message);
gameService.changeGameSetting(roomId, principal, message.getPayload().getMessage());
}

@MessageMapping("/room/round/{roomId}")
public void roundChange(
@DestinationVariable Long roomId,
Message<DefaultWebSocketRequest<RoundChangeRequest>> message) {
UserPrincipal principal = getSessionUser(message);
gameService.changeGameSetting(roomId, principal, message.getPayload().getMessage());
}
}
Loading