Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
@@ -0,0 +1,39 @@
package org.ezcode.codetest.application.game.dto.response.encounter;

import java.time.LocalDateTime;

import org.ezcode.codetest.domain.game.model.encounter.BattleHistory;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

@Builder
@Schema(description = "캐릭터 방어 전투 결과 응답")
public record DefenceBattleHistoryResponse(

@Schema(description = "공격자 캐릭터 닉네임")
String attackerNickName,

@Schema(description = "플레이어 캐릭터 닉네임")
String PlayerNickName,

@Schema(description = "전투 로그")
String battleLog,

@Schema(description = "플레이어 승패 여부(이겼을시 true, 패배시 false)")
boolean isDefenderWin,

@Schema(description = "PVP 가 일어난 시점")
LocalDateTime battleCreatedAt

) {
public static DefenceBattleHistoryResponse from(BattleHistory history) {
return DefenceBattleHistoryResponse.builder()
.attackerNickName(history.getAttacker().getName())
.PlayerNickName(history.getDefender().getName())
.battleLog(history.getBattleLog())
.isDefenderWin(!history.getIsAttackerWin())
.battleCreatedAt(history.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.ezcode.codetest.application.game.dto.request.skill.SkillUnEquipRequest;
import org.ezcode.codetest.application.game.dto.response.character.CharacterStatusResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.BattleHistoryResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.DefenceBattleHistoryResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.EncounterResultResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.MatchingBattleResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.MatchingEncounterResponse;
Expand All @@ -18,6 +19,7 @@
import org.ezcode.codetest.application.game.dto.response.skill.SkillResponse;
import org.ezcode.codetest.common.security.util.JwtUtil;
import org.ezcode.codetest.domain.game.model.character.GameCharacter;
import org.ezcode.codetest.domain.game.model.encounter.BattleHistory;
import org.ezcode.codetest.domain.game.model.encounter.EncounterHistory;
import org.ezcode.codetest.domain.game.model.encounter.EncounterLog;
import org.ezcode.codetest.domain.game.model.encounter.MatchMessageTemplate;
Expand Down Expand Up @@ -160,25 +162,6 @@ public BattleHistoryResponse battle(Long playerId, BattleRequest request) {
);
}

@Transactional
public BattleHistoryResponse randomBattle(Long userId) {

GameCharacter player = characterService.getGameCharacter(userId);

GameCharacter enemy = encounterDomainService.getRandomEnemyCharacter(userId, player.getId());

BattleLog log = encounterDomainService.battle(player, enemy);

encounterDomainService.createBattleHistory(player, enemy, log);

return BattleHistoryResponse.of(
player.getName(),
enemy.getName(),
log.getMessages(),
log.getPlayerWin()
);
}

@Transactional
public MatchingBattleResponse randomBattleMatching(Long userId) {

Expand Down Expand Up @@ -222,4 +205,14 @@ public EncounterResultResponse encounterChoice(Long userId, EncounterChoiceReque
return EncounterResultResponse.from(history);
}

@Transactional
public List<DefenceBattleHistoryResponse> getPlayerDefenceHistory(Long userId) {

GameCharacter playerCharacter = characterService.getGameCharacter(userId);

List<BattleHistory> history = encounterDomainService.getCharacterBattleHistory(playerCharacter);

return history.stream().map(DefenceBattleHistoryResponse::from).toList();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ public void increase(Stat stat, double rate) {
case DATA_STRUCTURE:
this.def += rate / 10;
this.atk += rate / 10;
this.accuracy += rate / 10;
this.accuracy += rate / 5;
break;
case SPEED:
this.speed += rate / 10;
this.speed += rate / 5;
this.atk += rate / 10;
this.accuracy += rate / 10;
this.accuracy += rate / 5;
break;
case DEBUGGING:
this.crit += rate / 5;
Expand All @@ -101,7 +101,7 @@ public void increase(Stat stat, double rate) {
case OPTIMIZATION:
this.evasion += rate / 5;
this.def += rate / 10;
this.accuracy += rate / 10;
this.accuracy += rate / 5;
break;
default:
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ public interface BattleHistoryRepository {
void delete(BattleHistory battleHistory);

List<BattleHistory> findAll();

List<BattleHistory> findCreatedInLast24Hours(Long playerId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
public class GameEncounterDomainService {

private final CharacterEquipService characterEquipService;
private final SkillStrategyFactory skillStrategyFactory;
private final SkillStrategyFactory skillStrategyFactory;
private final EncounterStrategyFactory encounterFactory;

private final BattleHistoryRepository battleHistoryRepository;
Expand All @@ -65,13 +65,13 @@ public BattleLog battle(GameCharacter player, GameCharacter opponent) {

WeaponType playerWeaponType = playerItems.stream()
.filter(item -> item instanceof Weapon)
.map(item -> (WeaponType) item.getItemType())
.map(item -> (WeaponType)item.getItemType())
.findFirst()
.orElse(WeaponType.NOTHING);

WeaponType opponentWeaponType = opponentItems.stream()
WeaponType opponentWeaponType = opponentItems.stream()
.filter(item -> item instanceof Weapon)
.map(item -> (WeaponType) item.getItemType())
.map(item -> (WeaponType)item.getItemType())
.findFirst()
.orElse(WeaponType.NOTHING);

Expand Down Expand Up @@ -106,10 +106,12 @@ public BattleLog battle(GameCharacter player, GameCharacter opponent) {

if (attacker == playerContext) {
player.earnGold(100L);
battleLog.add("전투 승리보상으로 100 골드가 지급되었습니다.");
battleLog.add("%s(이)가 전투 승리보상으로 100 골드가 지급되었습니다.", playerContext.getName());
} else {
player.loseGold(25L);
battleLog.add("패배하여 25 골드를 갈취당했습니다.");
opponent.earnGold(25L);
battleLog.add("%s님이 %s님에게 패배하여 25 골드를 갈취당했습니다.", playerContext.getName(),
opponentContext.getName());
}

return battleLog;
Expand All @@ -128,10 +130,12 @@ public BattleLog battle(GameCharacter player, GameCharacter opponent) {

if (defender == playerContext) {
player.earnGold(100L);
battleLog.add("전투 승리보상으로 100 골드가 지급되었습니다.");
battleLog.add("%s님에게 전투 승리보상으로 100 골드가 지급되었습니다.", playerContext.getName());
} else {
player.loseGold(25L);
battleLog.add("패배하여 25 골드를 갈취당했습니다.");
opponent.earnGold(25L);
battleLog.add("%s님이 %s님에게 패배하여 25 골드를 갈취당했습니다.", playerContext.getName(),
opponentContext.getName());
}

return battleLog;
Expand All @@ -140,7 +144,7 @@ public BattleLog battle(GameCharacter player, GameCharacter opponent) {
currentSkillIndex = (currentSkillIndex + 1) % 3;
}

battleLog.add("전투가 종료되었습니다. 양쪽 모두 살아남았습니다. 무승부입니다!");
battleLog.add("전투가 종료되었습니다. 양쪽 모두 살아남았습니다. 무승부입니다.");
battleLog.setPlayerWin(false);
return battleLog;
}
Expand Down Expand Up @@ -175,7 +179,7 @@ public List<BattleHistory> getBattleHistory(GameCharacter character) {

public GameCharacter getRandomEnemyCharacter(Long userId, Long playerId) {

GameCharacterMatchTokenBucket playerTokenBucket = matchTokenBucketRepository.findByCharacterId(playerId)
GameCharacterMatchTokenBucket playerTokenBucket = matchTokenBucketRepository.findByCharacterId(playerId)
.orElseThrow(() -> new GameException(GameExceptionCode.PLAYER_TOKEN_BUCKET_NOT_EXISTS));

playerTokenBucket.consumeBattleToken();
Expand All @@ -186,7 +190,7 @@ public GameCharacter getRandomEnemyCharacter(Long userId, Long playerId) {

public RandomEncounter getRandomEncounter(Long playerId) {

GameCharacterMatchTokenBucket playerTokenBucket = matchTokenBucketRepository.findByCharacterId(playerId)
GameCharacterMatchTokenBucket playerTokenBucket = matchTokenBucketRepository.findByCharacterId(playerId)
.orElseThrow(() -> new GameException(GameExceptionCode.PLAYER_TOKEN_BUCKET_NOT_EXISTS));

playerTokenBucket.consumeEncounterToken();
Expand Down Expand Up @@ -217,7 +221,7 @@ public EncounterLog encounterHappen(GameCharacter player, Long encounterId, bool
CharacterContext playerContext = CharacterContext.from(player.getName(), playerStats);

Inventory playerInventory = inventoryRepository.findByGameCharacterId(player.getId())
.orElseThrow(() -> new GameException(GameExceptionCode.INVENTORY_NOT_FOUND));
.orElseThrow(() -> new GameException(GameExceptionCode.INVENTORY_NOT_FOUND));

EncounterLog resultLog = new EncounterLog();

Expand All @@ -235,4 +239,9 @@ public EncounterHistory createEncounterHistory(GameCharacter player, EncounterLo
.build());
}

public List<BattleHistory> getCharacterBattleHistory(GameCharacter player) {

return battleHistoryRepository.findCreatedInLast24Hours(player.getId());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public boolean useSkill(CharacterContext attacker, CharacterContext defender, Ba

if (RNG.nextDouble() * 100 < attacker.getStun()) {
defender.consumeActionPoints();
log.add("스턴! %s가 잠시 머리를 잃었습니다 — 행동력 1 감소 → 남은 AP %d", defender.getName(), defender.getAp());
log.add("스턴! %s(이)가 잠시 정신을 잃었습니다 — 행동력 1 감소 → 남은 AP %d", defender.getName(), defender.getAp());
}

log.add("[%s] 남은 체력: %,.1f | [%s] 남은 체력: %,.1f", attacker.getName(), attacker.getHp(), defender.getName(), defender.getHp());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ public StatUpdateUtil() {
}

public Map<Stat, Double> getStatIncreasePerProblem(String categoryDescription) {
//TODO : 나중에 NULL 처리하기
return increasedStatRate.get(categoryDescription).asImmutableMap();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ public CacheManager cacheManager() {
.expireAfterWrite(Duration.ofMinutes(1))
.build());

CaffeineCache historyCache = new CaffeineCache("histories",
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(1))
.build());

CaffeineCache skillCache = new CaffeineCache("skill",
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
Expand All @@ -38,8 +43,14 @@ public CacheManager cacheManager() {
.expireAfterWrite(Duration.ofMinutes(10))
.build());

CaffeineCache choiceCache = new CaffeineCache("choices",
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.build());

SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(List.of(skillCache, itemsByCategoryCache, encountersCache, countCache));
manager.setCaches(
List.of(skillCache, itemsByCategoryCache, encountersCache, countCache, historyCache, choiceCache));
return manager;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ protected Principal determineUser(
if (query != null && query.startsWith("token=")) {
tokenParam = query.substring(6);
}
//TODO : 토큰의 대한 예외처리 아직 구현 x

Claims claims = jwtUtil.extractClaims(tokenParam);
String email = claims.get("email", String.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public Optional<GameCharacter> findByUserId(Long userId) {
public Optional<GameCharacter> findRandomCharacter(Long userId) {

long count = characterRepository.count();
if (count == 0) return Optional.empty();
if (count == 0 || count == 1) return Optional.empty();

for (int i = 0; i < 10; i++) {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package org.ezcode.codetest.infrastructure.persistence.repository.game.mysql.encounter;

import java.time.LocalDateTime;
import java.util.List;

import org.ezcode.codetest.domain.game.model.encounter.BattleHistory;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BattleHistoryJpaRepository extends JpaRepository<BattleHistory, Long> {
Expand All @@ -12,4 +14,8 @@ public interface BattleHistoryJpaRepository extends JpaRepository<BattleHistory,
List<BattleHistory> findByDefenderId(Long characterId);

List<BattleHistory> findByAttackerIdOrDefenderId(Long attackerId, Long defenderId);

@EntityGraph(attributePaths = {"attacker", "defender"})
List<BattleHistory> findByDefenderIdAndCreatedAtAfterOrderByCreatedAtDesc(Long defenderId,
LocalDateTime last24Hours);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package org.ezcode.codetest.infrastructure.persistence.repository.game.mysql.encounter;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import org.ezcode.codetest.domain.game.model.encounter.BattleHistory;
import org.ezcode.codetest.domain.game.repository.BattleHistoryRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Repository;

import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -56,4 +58,12 @@ public List<BattleHistory> findAll() {

return battleHistoryRepository.findAll();
}

@Override
@Cacheable(value = "histories", key = "#playerId")
public List<BattleHistory> findCreatedInLast24Hours(Long playerId) {

return battleHistoryRepository.findByDefenderIdAndCreatedAtAfterOrderByCreatedAtDesc(playerId,
LocalDateTime.now().minusDays(1));
Comment on lines +66 to +67
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

시간대 처리를 명시적으로 관리하세요.

LocalDateTime.now()는 시스템 시간대에 의존하므로 다국가 서비스에서 예상치 못한 결과를 초래할 수 있습니다. UTC 기준으로 시간을 처리하거나 애플리케이션의 표준 시간대를 명시적으로 사용하는 것을 권장합니다.

-		return battleHistoryRepository.findByDefenderIdAndCreatedAtAfterOrderByCreatedAtDesc(playerId,
-			LocalDateTime.now().minusDays(1));
+		return battleHistoryRepository.findByDefenderIdAndCreatedAtAfterOrderByCreatedAtDesc(playerId,
+			LocalDateTime.now(ZoneOffset.UTC).minusDays(1));

추가로 java.time.ZoneOffset import가 필요합니다.

🤖 Prompt for AI Agents
In
src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/encounter/BattleHistoryRepositoryImpl.java
around lines 66 to 67, replace LocalDateTime.now() with
LocalDateTime.now(ZoneOffset.UTC) to explicitly use UTC timezone instead of the
system default. This ensures consistent time handling across different regions.
Also, add the necessary import for java.time.ZoneOffset at the top of the file.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import org.ezcode.codetest.domain.game.model.encounter.EncounterChoice;
import org.ezcode.codetest.domain.game.repository.EncounterChoiceRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Repository;

import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -34,6 +35,7 @@ public Optional<EncounterChoice> findByName(String name) {
}

@Override
@Cacheable(value = "choices", key = "#encounterId + '-' + #playerDecision")
Copy link

@coderabbitai coderabbitai bot Jun 23, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

캐시 키 생성 방식을 개선하세요.

현재 문자열 연결 방식("#encounterId + '-' + #playerDecision")은 잠재적인 키 충돌을 일으킬 수 있습니다. SpEL의 컬렉션 구문을 사용하여 더 안전한 키를 생성하는 것을 권장합니다.

-	@Cacheable(value = "choices", key = "#encounterId + '-' + #playerDecision")
+	@Cacheable(value = "choices", key = "{#encounterId, #playerDecision}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Cacheable(value = "choices", key = "#encounterId + '-' + #playerDecision")
@Cacheable(value = "choices", key = "{#encounterId, #playerDecision}")
🤖 Prompt for AI Agents
In
src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/encounter/EncounterChoiceRepositoryImpl.java
at line 38, the cache key is currently generated by concatenating strings, which
can cause key collisions. Replace the string concatenation with a SpEL
collection syntax like "key = {#encounterId, #playerDecision}" to create a
safer, composite cache key.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@Cacheable(value = "choices", key = "#encounterId + '-' + #playerDecision")

  • @Cacheable(value = "choices", key = "{#encounterId, #playerDecision}") 이거 두개 차이가 뭔데 ?

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@coderabbitai 둘차이가 머냐고

Copy link

Choose a reason for hiding this comment

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

@chat26666 두 방식의 주요 차이점을 설명드리겠습니다:

1. 문자열 연결 방식 (현재)

@Cacheable(value = "choices", key = "#encounterId + '-' + #playerDecision")
  • encounterIdplayerDecision을 문자열로 변환해서 -로 연결
  • 예시: encounterId=123, playerDecision=true → 캐시키: "123-true"

2. SpEL 컬렉션 구문 (권장)

@Cacheable(value = "choices", key = "{#encounterId, #playerDecision}")
  • 두 값을 배열 형태로 묶어서 구조화된 복합 키 생성
  • 예시: encounterId=123, playerDecision=true → 캐시키: [123, true]

주요 차이점

1. 키 충돌 위험성

  • 문자열 연결: 잠재적 충돌 가능
    • encounterId=12, playerDecision=true"12-true"
    • encounterId=1, playerDecision="2-true""1-2-true" (만약 boolean이 아닌 다른 타입이라면)
  • 컬렉션 구문: 충돌 위험 없음 (각 요소가 독립적으로 처리됨)

2. 타입 안전성

  • 문자열 연결: 모든 값이 문자열로 변환됨
  • 컬렉션 구문: 각 요소의 원본 타입 유지

3. 성능

  • 문자열 연결: 문자열 생성/연결 오버헤드
  • 컬렉션 구문: 더 효율적인 해시 계산

컬렉션 구문 {#encounterId, #playerDecision}이 더 안전하고 권장되는 방식입니다! 🐰

  /|   /|  
 ( :v:  )
  |(_)|

🧠 Learnings used
Learnt from: chat26666
PR: ezcode-my/backend#64
File: src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/EncounterChoiceRepositoryImpl.java:0-0
Timestamp: 2025-06-15T04:37:29.231Z
Learning: EncounterChoiceRepositoryImpl in src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/EncounterChoiceRepositoryImpl.java is intentionally a skeleton implementation that is work-in-progress and will be completed later.

public List<EncounterChoice> findChoiceByPlayerDecision(Long encounterId, boolean playerDecision) {

return encounterRepository.findByEncounterIdAndPlayerDecision(encounterId, playerDecision);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.ezcode.codetest.application.game.dto.request.skill.SkillUnEquipRequest;
import org.ezcode.codetest.application.game.dto.response.character.CharacterStatusResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.BattleHistoryResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.DefenceBattleHistoryResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.EncounterResultResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.MatchingBattleResponse;
import org.ezcode.codetest.application.game.dto.response.encounter.MatchingEncounterResponse;
Expand Down Expand Up @@ -209,6 +210,22 @@ public ResponseEntity<BattleHistoryResponse> battle(
.body(gamePlayUseCase.battle(authUser.getId(), request));
}

@Operation(
summary = "방어 PVP 기록 조회 API",
description = "상대방이 자신을 대상으로 한 PVP 기록을 조회합니다.(24시간전까지만)",
responses = {
@ApiResponse(responseCode = "200", description = "PVP 결과 반환")
}
)
@ResponseMessage("정상적으로 PVP 기록 조회에 성공하였습니다.")
@GetMapping("/characters/battles")
public ResponseEntity<List<DefenceBattleHistoryResponse>> getDefenceBattleHistory(
@AuthenticationPrincipal AuthUser authUser
) {
return ResponseEntity.status(HttpStatus.OK)
.body(gamePlayUseCase.getPlayerDefenceHistory(authUser.getId()));
}

@Operation(
summary = "무작위 배틀 매칭 API",
description = "무작위로 다른 캐릭터 중 하나를 매칭시켜줍니다.",
Expand Down
Loading