Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
@@ -1,11 +1,17 @@
package io.f1.backend.domain.game.app;

import static io.f1.backend.domain.game.mapper.RoomMapper.toGameResultListResponse;
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.toRankUpdateResponse;
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.domain.quiz.mapper.QuizMapper.toGameStartResponse;

import io.f1.backend.domain.game.dto.MessageType;
import io.f1.backend.domain.game.event.RoomUpdatedEvent;
import io.f1.backend.domain.game.model.ConnectionState;
import io.f1.backend.domain.game.model.Player;
import io.f1.backend.domain.game.model.Room;
import io.f1.backend.domain.game.model.RoomState;
Expand All @@ -24,6 +30,7 @@
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
Expand All @@ -34,9 +41,10 @@ public class GameService {

private static final int START_DELAY = 5;

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 @@ -64,12 +72,51 @@ 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));
}

public void gameEnd(Room room) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

[L5-참고의견]
랭킹 정보 업데이트에 대한 TODO 주석을 달아두는 것도 좋을 것 같습니다.

room.updateRoomState(RoomState.FINISHED);
Copy link
Collaborator

Choose a reason for hiding this comment

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

finished가 있다면 현재 입장시에 waiting일때만 입장 가능하게 만들어야겠군요!
현재는 Playing이면 예외처리를 해놓은 상태입니다.
결과 보여주는 3초? 일때는 finished 유지해서 입장을 막고, waiting으로 바뀌었을때 입장 가능하게 만드는게
유저 경험을 고려했을때 더 깔끔하지 않을까 하는 생각입니다!

Copy link
Collaborator

Choose a reason for hiding this comment

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

해당 조건에 대한 예시를 생각해 봤을 때 카트라이더가 생각났습니다.
카트라이더는 게임이 끝나 순위가 나오는 동안은 다른 유저들이 방에 입장할 수 있는 것으로 알고 있습니다.

뇌이싱의 경우에도 결과 순위가 보이는 동안 유저가 입장하는 것은 논리상으로는 문제 되지 않을 것이라 생각되지만,
결과 화면에서 @@ 님이 입장했습니다와 함께 새로운 참여자의 채팅이 노출된다면 조금 당황스러울 것 같기는 합니다.

추가로, FINISHED 상태를 추가한다면 방 목록의 입장에서는 어떻게 보여줄 것인지에 대한 고민도 필요할 것 같습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

-> waiting, playing 상태만 유지하는 것이 고민거리를 줄일 수 있지 않을까 생각됩니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

세희님, 경찬님의 의견을 듣고 고민을 해봤는데, 확실히 finished 상태까지 관리하게 되면 분기처리해줘야 할 사항들이 많다고 생각했습니다.

사실상 게임이 끝나고 waiting 상태로 변경해준다하더라도, 누군가 나가야 게임 방에 들어올 수 있게 되는 것이고, 게임이 끝나고 게임 결과 창을 닫는 타이밍은 플레이어 개별에게 달려있는 것이라 바로 waiting 상태로 변경 후, 기존 게임 대기 상태에서 이뤄질 수 있는 것들이 똑같이 이뤄져도 괜찮겠다 생각하여 상태를 두 가지로 두는 방향으로 변경했습니다 !


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

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

messageSender.send(
destination,
MessageType.GAME_RESULT,
toGameResultListResponse(playerSessionMap, room.getGameSetting().getRound()));

List<Player> disconnectedPlayers = new ArrayList<>();

room.initializeRound();
for (Player player : playerSessionMap.values()) {
if (player.getState().equals(ConnectionState.DISCONNECTED)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Player의 내부 메서드로 분리하면 좋을 것 같습니다 !

disconnectedPlayers.add(player);
}
player.initializeCorrectCount();
player.toggleReady();
}

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들을 처리하는 방식으로 구현했습니다 !

for (Player player : disconnectedPlayers) {
String sessionId = room.getUserIdSessionMap().get(player.id);
Copy link
Collaborator

Choose a reason for hiding this comment

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

이 부분도 Room 내부 메서드로 분리할 수 있을 것 같습니다 !

roomService.exitRoomForDisconnectedPlayer(roomId, player, sessionId);
}

room.updateRoomState(RoomState.WAITING);
messageSender.send(destination, MessageType.PLAYER_LIST, toPlayerListResponse(room));
messageSender.send(
destination,
MessageType.GAME_SETTING,
toGameSettingResponse(room.getGameSetting(), room.getCurrentQuestion().getQuiz()));
messageSender.send(destination, MessageType.ROOM_SETTING, toRoomSettingResponse(room));
}

private boolean validateReadyStatus(Room room) {

Map<String, Player> playerSessionMap = room.getPlayerSessionMap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
@RequiredArgsConstructor
public class RoomService {

private final GameService gameService;
private final TimerService timerService;
private final QuizService quizService;
private final RoomRepository roomRepository;
Expand Down Expand Up @@ -263,9 +264,8 @@ public void chat(Long roomId, String sessionId, ChatMessage chatMessage) {

timerService.cancelTimer(room);

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

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

public void exitRoomForDisconnectedPlayer(Long roomId, Player player, String sessionId) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

[L4-변경제안]
exitRoom과 겹치는 코드는 공통 메서드로 따로 분리해서 사용해도 좋을것 같습니다!


// 연결 끊긴 플레이어 exit 로직 타게 해주기
Room room = findRoom(roomId);

/* 방 삭제 */
if (isLastPlayer(room, sessionId)) {
removeRoom(room);
return;
}

/* 방장 변경 */
if (room.isHost(player.getId())) {
changeHost(room, sessionId);
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

방 삭제와 방장 변경은 동시성이 필요해보입니다!

제가 이번 pr에서 방장 변경은 connect 상태인 유저만 대상이 될 수 있게 로직을 추가하긴했는데...
만약 방장이 중간에 나간다면 게임 종료 후에 exitRoomForDisconnectedPlayer 이 로직을 타야 방장이 바뀌겠군요 ,,,
그렇다면 이 로직도 동시성 적용이 되어야 할듯 합니다.

다른 유저랑 동시에 나가면 방 삭제, 방장 변경이 제대로 되지 않을 것 같습니다.

/* 플레이어 삭제 */
removePlayer(room, sessionId, player);

SystemNoticeResponse systemNoticeResponse =
ofPlayerEvent(player.nickname, RoomEventType.EXIT);

String destination = getDestination(roomId);

messageSender.send(destination, MessageType.SYSTEM_NOTICE, systemNoticeResponse);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public class TimerService {
private static final String NONE_CORRECT_USER = "";
private static final int CONTINUE_DELAY = 3;

private final GameService gameService;

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

Expand Down Expand Up @@ -53,14 +55,11 @@ private void handleTimeout(Room room) {
MessageType.SYSTEM_NOTICE,
ofPlayerEvent(NONE_CORRECT_USER, RoomEventType.TIMEOUT));

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

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

startTimer(room, CONTINUE_DELAY);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ public enum MessageType {
CHAT,
QUESTION_RESULT,
RANK_UPDATE,
QUESTION_START
QUESTION_START,
GAME_RESULT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.f1.backend.domain.game.dto.response;

import java.util.List;

public record GameResultListResponse(List<GameResultResponse> result) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package io.f1.backend.domain.game.dto.response;

public record GameResultResponse(String nickname, int score, int totalCorrectCount, int rank) {}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
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.GameResultListResponse;
import io.f1.backend.domain.game.dto.response.GameResultResponse;
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;
Expand All @@ -21,8 +23,10 @@
import io.f1.backend.domain.quiz.entity.Quiz;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;

public class RoomMapper {

Expand Down Expand Up @@ -109,4 +113,42 @@ public static QuestionStartResponse toQuestionStartResponse(Room room, int delay
room.getCurrentRound(),
Instant.now().plusSeconds(delay));
}

public static GameResultResponse toGameResultResponse(
Player player, int round, int rank, int totalPlayers) {
double correctRate = (double) player.getCorrectCount() / round;
int score = (int) (correctRate * 100) + (totalPlayers - rank) * 5;

return new GameResultResponse(player.nickname, score, player.getCorrectCount(), rank);
}

public static GameResultListResponse toGameResultListResponse(
Map<String, Player> playerSessionMap, int round) {

List<Player> rankedPlayers =
playerSessionMap.values().stream()
.sorted(Comparator.comparingInt(Player::getCorrectCount).reversed())
.toList();

int totalPlayers = rankedPlayers.size();

int prevCorrectCnt = -1;
int rank = 0;

List<GameResultResponse> gameResults = new ArrayList<>();
for (int i = 0; i < totalPlayers; i++) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

해당 반복문을 메서드로 분리하면 GameResultListResponse로 변환하는 동작이 더 잘 드러날 것 같습니다 !

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Mapper에는 오로지 매핑 (A->B) 의 역할을 가진 메서드만 존재해야 한다고 생각하여, 구분해두지 않았는데, 가독성면에서 분리하는 것이 맞다고 생각해서 분리했습니다.

또, 해당 계산 메서드를 도메인에 빼는 것은 확실히 책임 분리가 되지 않는 것이라 생각되어 매퍼 클래스 안에서 private으로 두는 것으로 책임과 가독성을 균형을 최대한 맞추는 방향으로 분리했습니다 !

Player player = rankedPlayers.get(i);

int correctCnt = player.getCorrectCount();

if (prevCorrectCnt != correctCnt) {
rank = i + 1;
}

gameResults.add(toGameResultResponse(player, round, rank, totalPlayers));
prevCorrectCnt = correctCnt;
}

return new GameResultListResponse(gameResults);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ public void toggleReady() {
public void increaseCorrectCount() {
correctCount++;
}

public void initializeCorrectCount() {
correctCount = 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,8 @@ public Boolean isPlaying() {
public void increaseCurrentRound() {
currentRound++;
}

public void initializeRound() {
currentRound = 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class RoomServiceTests {

@Mock private RoomRepository roomRepository;
@Mock private QuizService quizService;
@Mock private GameService gameService;
@Mock private TimerService timerService;
@Mock private ApplicationEventPublisher eventPublisher;
@Mock private MessageSender messageSender;
Expand All @@ -54,7 +55,12 @@ void setUp() {
MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다.
roomService =
new RoomService(
timerService, quizService, roomRepository, eventPublisher, messageSender);
gameService,
timerService,
quizService,
roomRepository,
eventPublisher,
messageSender);

SecurityContextHolder.clearContext();
}
Expand Down
Loading