From ef358650d0ba86a03c988d518f7e298fbd824a23 Mon Sep 17 00:00:00 2001 From: chat26666 Date: Sat, 21 Jun 2025 15:08:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20=EB=9E=9C=EB=8D=A4=20=EC=9D=B8?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=84=B0=20=EC=B6=94=EA=B0=80,=20jwt=20?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=9C=20=EC=9D=BC=EC=8B=9C?= =?UTF-8?q?=EC=A0=81=EC=9D=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=ED=95=98=EB=A3=A8=EC=97=90=20=EC=9D=B8=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=84=B0,=20=EB=B0=B0=ED=8B=80=20=ED=9A=9F=EC=88=98?= =?UTF-8?q?=20=EC=A0=9C=ED=95=9C=20=ED=86=A0=ED=81=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/encounter/BattleRequest.java | 15 ++++ .../encounter/EncounterChoiceRequest.java | 5 ++ .../encounter/RandomEncounterSaveRequest.java | 12 ++- .../response/encounter/EncounterResponse.java | 6 ++ .../encounter/MatchingBattleResponse.java | 8 +- .../encounter/MatchingEncounterResponse.java | 16 +++- .../game/play/GamePlayUseCase.java | 53 ++++++++---- .../auth/service/AuthService.java | 8 +- .../security/hander/CustomSuccessHandler.java | 2 +- .../common/security/util/JwtUtil.java | 19 +++- .../game/exception/GameExceptionCode.java | 5 +- .../game/model/character/GameCharacter.java | 9 +- .../GameCharacterMatchTokenBucket.java | 86 +++++++++++++++++++ .../game/model/encounter/EncounterLog.java | 2 +- .../game/model/encounter/RandomEncounter.java | 10 +++ .../MatchTokenBucketRepository.java | 13 +++ .../service/CharacterStatusDomainService.java | 6 ++ .../service/GameEncounterDomainService.java | 39 +++++++-- .../AmbushBanditsBad.java | 2 +- .../AmbushBanditsGood.java | 2 +- .../AncientRuinsTrap.java | 2 +- .../BossEncounterBad.java | 3 +- .../encounterstrategyimpl/GamblingBad.java | 2 +- .../encounterstrategyimpl/StatSpeed.java | 7 +- .../TreasureCacheFound.java | 2 +- .../WildBeastsAttack.java | 27 ++++-- .../WildBeastsEscape.java | 27 +++--- .../DefencePenetrationDecorator.java | 2 +- .../skillstrategyimpl/ButterflySkill.java | 2 +- .../DefencePenetrationSkill.java | 2 +- .../skill/skillstrategyimpl/NoSkill.java | 4 +- .../MatchTokenBucketJpaRepository.java | 16 ++++ .../MatchTokenBucketRepositoryImpl.java | 27 ++++++ .../game/play/GamePlayController.java | 33 ++----- 34 files changed, 374 insertions(+), 100 deletions(-) create mode 100644 src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/BattleRequest.java create mode 100644 src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacterMatchTokenBucket.java create mode 100644 src/main/java/org/ezcode/codetest/domain/game/repository/MatchTokenBucketRepository.java create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketJpaRepository.java create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketRepositoryImpl.java diff --git a/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/BattleRequest.java b/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/BattleRequest.java new file mode 100644 index 00000000..6a6bbb71 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/BattleRequest.java @@ -0,0 +1,15 @@ +package org.ezcode.codetest.application.game.dto.request.encounter; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "배틀 요청") +public record BattleRequest( + + @Schema(description = "배틀 토큰") + @NotBlank(message = "배틀 토큰은 반드시 입력해야합니다.") + String battleToken + +) { +} + diff --git a/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/EncounterChoiceRequest.java b/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/EncounterChoiceRequest.java index 76d2e331..5064c982 100644 --- a/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/EncounterChoiceRequest.java +++ b/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/EncounterChoiceRequest.java @@ -1,11 +1,16 @@ package org.ezcode.codetest.application.game.dto.request.encounter; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @Schema(description = "인카운터 선택지 결정 요청") public record EncounterChoiceRequest( + @Schema(description = "인카운터 토큰") + @NotBlank(message = "인카운터 토큰은 반드시 입력해야합니다.") + String encounterToken, + @NotNull(message = "인카운터를 선택지를 선택해주세요.(true, false)") @Schema(description = "인카운터 선택지 결정(true:yes, false:no)") Boolean playerDecision diff --git a/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/RandomEncounterSaveRequest.java b/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/RandomEncounterSaveRequest.java index 2fe91feb..4c363747 100644 --- a/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/RandomEncounterSaveRequest.java +++ b/src/main/java/org/ezcode/codetest/application/game/dto/request/encounter/RandomEncounterSaveRequest.java @@ -24,7 +24,15 @@ public record RandomEncounterSaveRequest( @NotBlank(message = "설명란은 필수입니다.") @Schema(description = "저장할 인카운터의 설명 및 묘사") - String encounterText + String encounterText, + + @NotBlank(message = "인카운터 선택지 1 입력은 필수입니다.") + @Schema(description = "인카운터 선택지 1 메시지") + String choice1Text, + + @NotBlank(message = "인카운터 선택지 2 입력은 필수입니다.") + @Schema(description = "인카운터 선택지 2 메시지") + String choice2Text ) { public RandomEncounter toRandomEncounter() { @@ -33,6 +41,8 @@ public RandomEncounter toRandomEncounter() { .activated(true) .name(name) .encounterText(encounterText) + .choice1Text(choice1Text) + .choice2Text(choice2Text) .build(); } } diff --git a/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/EncounterResponse.java b/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/EncounterResponse.java index 7ba63be9..846e57a5 100644 --- a/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/EncounterResponse.java +++ b/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/EncounterResponse.java @@ -19,6 +19,12 @@ public record EncounterResponse( @Schema(description = "인카운터 설명 및 묘사") String encounterText, + @Schema(description = "인카운터 선택지 1") + String choice1Text, + + @Schema(description = "인카운터 선택지 2") + String choice2Text, + @Schema(description = "현재 인카운터 활성 여부") boolean activated diff --git a/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/MatchingBattleResponse.java b/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/MatchingBattleResponse.java index 4568f0d2..a98ea481 100644 --- a/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/MatchingBattleResponse.java +++ b/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/MatchingBattleResponse.java @@ -11,12 +11,12 @@ public record MatchingBattleResponse( @Schema(description = "두 플레이어 간 조우했을 시 상황묘사") String message, - @Schema(description = "적 플레이어의 캐릭터 ID") - Long enemyId + @Schema(description = "적 플레이어의 캐릭터(jwtToken) ID") + String enemyIdToken ) { - public static MatchingBattleResponse of(boolean isEnemyStrongThanMe, String message, Long enemyId) { + public static MatchingBattleResponse of(boolean isEnemyStrongThanMe, String message, String enemyIdToken) { - return new MatchingBattleResponse(isEnemyStrongThanMe, message, enemyId); + return new MatchingBattleResponse(isEnemyStrongThanMe, message, enemyIdToken); } } diff --git a/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/MatchingEncounterResponse.java b/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/MatchingEncounterResponse.java index db20c18c..cd8b7498 100644 --- a/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/MatchingEncounterResponse.java +++ b/src/main/java/org/ezcode/codetest/application/game/dto/response/encounter/MatchingEncounterResponse.java @@ -10,8 +10,8 @@ @Schema(description = "인카운터 매칭 요청에 대한 응답") public record MatchingEncounterResponse( - @Schema(description = "인카운터 ID") - Long id, + @Schema(description = "인카운터 ID(jwt 토큰)") + String id, @Schema(description = "인카운터 카테고리") EncounterCategory encounterCategory, @@ -19,17 +19,25 @@ public record MatchingEncounterResponse( @Schema(description = "인카운터 이름") String name, + @Schema(description = "인카운터 선택지 1") + String choice1Text, + + @Schema(description = "인카운터 선택지 2") + String choice2Text, + @Schema(description = "인카운터 설명/묘사") String encounterText ) { - public static MatchingEncounterResponse from(RandomEncounter encounter) { + public static MatchingEncounterResponse from(RandomEncounter encounter, String encounterIdToken) { return MatchingEncounterResponse.builder() .encounterCategory(encounter.getEncounterCategory()) - .id(encounter.getId()) + .id(encounterIdToken) .name(encounter.getName()) .encounterText(encounter.getEncounterText()) + .choice1Text(encounter.getChoice1Text()) + .choice2Text(encounter.getChoice2Text()) .build(); } } diff --git a/src/main/java/org/ezcode/codetest/application/game/play/GamePlayUseCase.java b/src/main/java/org/ezcode/codetest/application/game/play/GamePlayUseCase.java index 3e9d074d..50c6bb77 100644 --- a/src/main/java/org/ezcode/codetest/application/game/play/GamePlayUseCase.java +++ b/src/main/java/org/ezcode/codetest/application/game/play/GamePlayUseCase.java @@ -3,6 +3,7 @@ import java.util.List; import org.ezcode.codetest.application.game.dto.mapper.GameMapper; +import org.ezcode.codetest.application.game.dto.request.encounter.BattleRequest; import org.ezcode.codetest.application.game.dto.request.encounter.EncounterChoiceRequest; import org.ezcode.codetest.application.game.dto.request.skill.SkillEquipRequest; import org.ezcode.codetest.application.game.dto.request.skill.SkillUnEquipRequest; @@ -15,6 +16,7 @@ import org.ezcode.codetest.application.game.dto.response.item.ItemResponse; import org.ezcode.codetest.application.game.dto.response.skill.SkillGamblingResponse; 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.EncounterHistory; import org.ezcode.codetest.domain.game.model.encounter.EncounterLog; @@ -33,6 +35,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; @Service @@ -44,6 +47,7 @@ public class GamePlayUseCase { private final UserDomainService userDomainService; private final GameEncounterDomainService encounterDomainService; private final GameMapper gameMapper; + private final JwtUtil jwtUtil; @Transactional public void createCharacter(String email) { @@ -134,9 +138,14 @@ public SkillGamblingResponse gamblingForSkill(Long userId) { } @Transactional - public BattleHistoryResponse battle(Long playerId, Long enemyId) { + public BattleHistoryResponse battle(Long playerId, BattleRequest request) { + + Claims claims = jwtUtil.extractClaims(request.battleToken()); + + Long enemyId = Long.valueOf(claims.getSubject()); GameCharacter playerCharacter = characterService.getGameCharacter(playerId); + GameCharacter enemyCharacter = characterService.getGameCharacter(enemyId); BattleLog log = encounterDomainService.battle(playerCharacter, enemyCharacter); @@ -152,50 +161,56 @@ public BattleHistoryResponse battle(Long playerId, Long enemyId) { } @Transactional - public BattleHistoryResponse randomBattle(Long playerId) { + public BattleHistoryResponse randomBattle(Long userId) { - GameCharacter playerCharacter = characterService.getGameCharacter(playerId); + GameCharacter player = characterService.getGameCharacter(userId); - GameCharacter enemyCharacter = encounterDomainService.getRandomEnemyCharacter(playerId); + GameCharacter enemy = encounterDomainService.getRandomEnemyCharacter(userId, player.getId()); - BattleLog log = encounterDomainService.battle(playerCharacter, enemyCharacter); + BattleLog log = encounterDomainService.battle(player, enemy); - encounterDomainService.createBattleHistory(playerCharacter, enemyCharacter, log); + encounterDomainService.createBattleHistory(player, enemy, log); return BattleHistoryResponse.of( - playerCharacter.getName(), - enemyCharacter.getName(), + player.getName(), + enemy.getName(), log.getMessages(), log.getPlayerWin() ); } @Transactional - public MatchingBattleResponse randomBattleMatching(Long playerId) { + public MatchingBattleResponse randomBattleMatching(Long userId) { - GameCharacter playerCharacter = characterService.getGameCharacter(playerId); + GameCharacter player = characterService.getGameCharacter(userId); - GameCharacter enemyCharacter = encounterDomainService.getRandomEnemyCharacter(playerId); + GameCharacter enemy = encounterDomainService.getRandomEnemyCharacter(userId, player.getId()); - boolean checkStrength = encounterDomainService.compareStrength(playerCharacter, enemyCharacter); + boolean checkStrength = encounterDomainService.compareStrength(player, enemy); - String matchMessage = MatchMessageTemplate.random(playerCharacter.getName(), enemyCharacter.getName()); + String matchMessage = MatchMessageTemplate.random(player.getName(), enemy.getName()); - Long enemyUserId = enemyCharacter.getUser().getId(); + Long enemyUserId = enemy.getUser().getId(); - return MatchingBattleResponse.of(checkStrength, matchMessage, enemyUserId); + return MatchingBattleResponse.of(checkStrength, matchMessage, jwtUtil.createGameToken(enemyUserId)); } @Transactional - public MatchingEncounterResponse randomEncounterMatching() { + public MatchingEncounterResponse randomEncounterMatching(Long userId) { - RandomEncounter encounter = encounterDomainService.getRandomEncounter(); + GameCharacter playerCharacter = characterService.getGameCharacter(userId); - return MatchingEncounterResponse.from(encounter); + RandomEncounter encounter = encounterDomainService.getRandomEncounter(playerCharacter.getId()); + + return MatchingEncounterResponse.from(encounter, jwtUtil.createGameToken(encounter.getId())); } @Transactional - public EncounterResultResponse encounterChoice(Long userId, Long encounterId, EncounterChoiceRequest request) { + public EncounterResultResponse encounterChoice(Long userId, EncounterChoiceRequest request) { + + Claims claims = jwtUtil.extractClaims(request.encounterToken()); + + Long encounterId = Long.valueOf(claims.getSubject()); GameCharacter player = characterService.getGameCharacter(userId); diff --git a/src/main/java/org/ezcode/codetest/application/usermanagement/auth/service/AuthService.java b/src/main/java/org/ezcode/codetest/application/usermanagement/auth/service/AuthService.java index dab74357..ffa8ca97 100644 --- a/src/main/java/org/ezcode/codetest/application/usermanagement/auth/service/AuthService.java +++ b/src/main/java/org/ezcode/codetest/application/usermanagement/auth/service/AuthService.java @@ -68,7 +68,7 @@ public SignupResponse signup(SignupRequest signupRequest) { userDomainService.createUser(newUser); userDomainService.createUserAuthType(userAuthType); - bearToken = jwtUtil.createToken( + bearToken = jwtUtil.createAccessToken( newUser.getId(), newUser.getEmail(), newUser.getRole(), @@ -83,7 +83,7 @@ public SignupResponse signup(SignupRequest signupRequest) { existUser.modifyPassword(encodedPassword); log.info("유저 타입 저장 완료 {}", userAuthType); - bearToken = jwtUtil.createToken( + bearToken = jwtUtil.createAccessToken( existUser.getId(), existUser.getEmail(), existUser.getRole(), @@ -120,7 +120,7 @@ public SigninResponse signin(@Valid SigninRequest signinRequest) { log.info("비밀번호 체크 완료"); - String bearToken = jwtUtil.createToken( + String bearToken = jwtUtil.createAccessToken( loginUser.getId(), loginUser.getEmail(), loginUser.getRole(), @@ -182,7 +182,7 @@ public RefreshTokenResponse refreshToken(String token) { User user = userDomainService.getUserById(userId); log.info("유저 도메인서비스에서 유저 아이디로 유저 찾아옴"); - String newAccessToken = jwtUtil.createToken( + String newAccessToken = jwtUtil.createAccessToken( user.getId(), user.getEmail(), user.getRole(), diff --git a/src/main/java/org/ezcode/codetest/common/security/hander/CustomSuccessHandler.java b/src/main/java/org/ezcode/codetest/common/security/hander/CustomSuccessHandler.java index 31949e09..ff3f0080 100644 --- a/src/main/java/org/ezcode/codetest/common/security/hander/CustomSuccessHandler.java +++ b/src/main/java/org/ezcode/codetest/common/security/hander/CustomSuccessHandler.java @@ -46,7 +46,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo User loginUser = userDomainService.getUserByEmail(customUserDetails.getEmail()); log.info("loginUser: {}", loginUser); - String accessToken = jwtUtil.createToken( + String accessToken = jwtUtil.createAccessToken( loginUser.getId(), loginUser.getEmail(), loginUser.getRole(), diff --git a/src/main/java/org/ezcode/codetest/common/security/util/JwtUtil.java b/src/main/java/org/ezcode/codetest/common/security/util/JwtUtil.java index 36aa9574..1856da9f 100644 --- a/src/main/java/org/ezcode/codetest/common/security/util/JwtUtil.java +++ b/src/main/java/org/ezcode/codetest/common/security/util/JwtUtil.java @@ -23,6 +23,7 @@ public class JwtUtil { private static final String BEARER_PREFIX = "Bearer "; private static final long TOKEN_EXPIRATION_TIME = 60 * 60 * 24 * 7; + private static final long GAME_TOKEN_EXPIRATION_TIME = 60; @Value("${jwt.secret}") private String secretKey; @@ -39,7 +40,7 @@ public void init() { /* 토큰 발급 */ - public String createToken(Long userId, String email, UserRole userRole, String username, String nickname, Tier tier) { + public String createAccessToken(Long userId, String email, UserRole userRole, String username, String nickname, Tier tier) { if (userId == null || email == null || username == null || nickname == null) { throw new IllegalArgumentException("토큰에 필요한 필수 매개변수가 null입니다."); } @@ -60,6 +61,22 @@ public String createToken(Long userId, String email, UserRole userRole, String u .compact(); } + public String createGameToken(Long eventId) { + + if (eventId == null) { + throw new IllegalArgumentException("토큰에 필요한 필수 매개변수가 null입니다."); + } + + Date date = new Date(); + + return Jwts.builder() + .setSubject(String.valueOf(eventId)) + .setExpiration(new Date(date.getTime() + GAME_TOKEN_EXPIRATION_TIME * 1000L)) + .setIssuedAt(date) + .signWith(key, signatureAlgorithm) + .compact(); + } + /* 토큰 추출 */ diff --git a/src/main/java/org/ezcode/codetest/domain/game/exception/GameExceptionCode.java b/src/main/java/org/ezcode/codetest/domain/game/exception/GameExceptionCode.java index 3dedaf2f..fc0afac5 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/exception/GameExceptionCode.java +++ b/src/main/java/org/ezcode/codetest/domain/game/exception/GameExceptionCode.java @@ -28,7 +28,10 @@ public enum GameExceptionCode implements ResponseCode { RANDOM_ENCOUNTER_NOT_EXISTS(false, HttpStatus.BAD_REQUEST, "해당 랜덤 인카운터가 존재하지 않습니다."), RANDOM_ENCOUNTER_MATCHING_FAIL(false, HttpStatus.NOT_FOUND, "현재 매칭가능한 인카운터가 존재하지 않습니다."), ENCOUNTER_CHOICE_ALREADY_EXISTS(false, HttpStatus.BAD_REQUEST, "이미 존재하는 인카운터 선택지입니다."), - ENCOUNTER_CHOICE_NOT_EXISTS(false, HttpStatus.BAD_REQUEST, "해당 인카운터 선택지가 존재하지 않습니다."); + ENCOUNTER_CHOICE_NOT_EXISTS(false, HttpStatus.BAD_REQUEST, "해당 인카운터 선택지가 존재하지 않습니다."), + ENCOUNTER_TOKEN_EXHAUSTED(false, HttpStatus.BAD_REQUEST, "인카운터 매칭 토큰을 전부 소진했습니다. 6 시간 이후 리필됩니다."), + BATTLE_TOKEN_EXHAUSTED(false, HttpStatus.BAD_REQUEST, "배틀 매칭 토큰을 전부 소진했습니다. 6 시간 이후 리필됩니다."), + PLAYER_TOKEN_BUCKET_NOT_EXISTS(false, HttpStatus.NOT_FOUND, "해당 캐릭터의 매칭 토큰 버킷이 존재하지 않습니다."); private final boolean success; private final HttpStatus status; diff --git a/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacter.java b/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacter.java index a49fe7c1..a20c1f1e 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacter.java +++ b/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacter.java @@ -91,7 +91,8 @@ public void applyIncreaseStats(Map stats) { public void useGoldForGamble() { - if(gold < 50L) throw new GameException(GameExceptionCode.NOT_ENOUGH_GOLD); + if (gold < 50L) + throw new GameException(GameExceptionCode.NOT_ENOUGH_GOLD); gold -= 50L; } @@ -101,7 +102,11 @@ public void earnGold(long gold) { this.gold += gold; } - public void equipItem(ItemType item , String newItem) { + public void loseGold(long gold) { + this.gold = Math.max(0L, this.gold - gold); + } + + public void equipItem(ItemType item, String newItem) { if (item instanceof WeaponType) { weaponId = newItem; diff --git a/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacterMatchTokenBucket.java b/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacterMatchTokenBucket.java new file mode 100644 index 00000000..c60a26b4 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacterMatchTokenBucket.java @@ -0,0 +1,86 @@ +package org.ezcode.codetest.domain.game.model.character; + +import java.time.Duration; +import java.time.Instant; + +import org.ezcode.codetest.domain.game.exception.GameException; +import org.ezcode.codetest.domain.game.exception.GameExceptionCode; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "character_match_token_bucket") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GameCharacterMatchTokenBucket { + + private static final int MAX_BATTLE_TOKENS = 35; + private static final int MAX_ENCOUNTER_TOKENS = 10; + private static final Duration REFILL_INTERVAL = Duration.ofHours(6); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "game_character_id", nullable = false, unique = true) + private GameCharacter character; + + private int remainingBattleMatchTokens; + private int remainingEncounterMatchTokens; + + private Instant lastRefillTime; + + public GameCharacterMatchTokenBucket(GameCharacter character) { + + this.character = character; + remainingBattleMatchTokens = MAX_BATTLE_TOKENS; + remainingEncounterMatchTokens = MAX_ENCOUNTER_TOKENS; + lastRefillTime = Instant.now(); + } + + public void consumeBattleToken() { + + Instant now = Instant.now(); + + Duration sinceLastRefill = Duration.between(lastRefillTime, now); + if (sinceLastRefill.compareTo(REFILL_INTERVAL) >= 0) { + remainingBattleMatchTokens = MAX_BATTLE_TOKENS; + lastRefillTime = now; + } + + if (remainingBattleMatchTokens <= 0) { + throw new GameException(GameExceptionCode.BATTLE_TOKEN_EXHAUSTED); + } + + remainingBattleMatchTokens--; + } + + public void consumeEncounterToken() { + + Instant now = Instant.now(); + + Duration sinceLastRefill = Duration.between(lastRefillTime, now); + if (sinceLastRefill.compareTo(REFILL_INTERVAL) >= 0) { + remainingBattleMatchTokens = MAX_BATTLE_TOKENS; + lastRefillTime = now; + } + + if (remainingEncounterMatchTokens <= 0) { + throw new GameException(GameExceptionCode.ENCOUNTER_TOKEN_EXHAUSTED); + } + + remainingEncounterMatchTokens--; + } + +} \ No newline at end of file diff --git a/src/main/java/org/ezcode/codetest/domain/game/model/encounter/EncounterLog.java b/src/main/java/org/ezcode/codetest/domain/game/model/encounter/EncounterLog.java index 3e417c6e..de92cef3 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/model/encounter/EncounterLog.java +++ b/src/main/java/org/ezcode/codetest/domain/game/model/encounter/EncounterLog.java @@ -22,7 +22,7 @@ public void add(String format, Object... args) { } public String asText() { - return String.join("\n", messages); + return String.join(" ", messages); } } diff --git a/src/main/java/org/ezcode/codetest/domain/game/model/encounter/RandomEncounter.java b/src/main/java/org/ezcode/codetest/domain/game/model/encounter/RandomEncounter.java index a3d31320..46e4b7e9 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/model/encounter/RandomEncounter.java +++ b/src/main/java/org/ezcode/codetest/domain/game/model/encounter/RandomEncounter.java @@ -34,6 +34,12 @@ public class RandomEncounter extends BaseEntity { @Column(columnDefinition = "TEXT", nullable = false) private String encounterText; + @Column(nullable = false) + private String choice1Text; + + @Column(nullable = false) + private String choice2Text; + @Column(nullable = false) private boolean activated; @@ -42,11 +48,15 @@ public RandomEncounter( EncounterCategory encounterCategory, String name, String encounterText, + String choice1Text, + String choice2Text, boolean activated ) { this.encounterCategory = encounterCategory; this.name = name; this.encounterText = encounterText; + this.choice1Text = choice1Text; + this.choice2Text = choice2Text; this.activated = activated; } diff --git a/src/main/java/org/ezcode/codetest/domain/game/repository/MatchTokenBucketRepository.java b/src/main/java/org/ezcode/codetest/domain/game/repository/MatchTokenBucketRepository.java new file mode 100644 index 00000000..057c84c7 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/domain/game/repository/MatchTokenBucketRepository.java @@ -0,0 +1,13 @@ +package org.ezcode.codetest.domain.game.repository; + +import java.util.Optional; + +import org.ezcode.codetest.domain.game.model.character.GameCharacterMatchTokenBucket; + +public interface MatchTokenBucketRepository { + + GameCharacterMatchTokenBucket save(GameCharacterMatchTokenBucket bucket); + + Optional findByCharacterId(Long characterId); + +} diff --git a/src/main/java/org/ezcode/codetest/domain/game/service/CharacterStatusDomainService.java b/src/main/java/org/ezcode/codetest/domain/game/service/CharacterStatusDomainService.java index 578171dc..7e5808bc 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/service/CharacterStatusDomainService.java +++ b/src/main/java/org/ezcode/codetest/domain/game/service/CharacterStatusDomainService.java @@ -10,6 +10,7 @@ import org.ezcode.codetest.domain.game.exception.GameException; import org.ezcode.codetest.domain.game.exception.GameExceptionCode; import org.ezcode.codetest.domain.game.model.character.GameCharacter; +import org.ezcode.codetest.domain.game.model.character.GameCharacterMatchTokenBucket; import org.ezcode.codetest.domain.game.model.character.Stat; import org.ezcode.codetest.domain.game.model.skill.GameCharacterSkill; import org.ezcode.codetest.domain.game.model.character.Inventory; @@ -17,6 +18,7 @@ import org.ezcode.codetest.domain.game.repository.GameCharacterRepository; import org.ezcode.codetest.domain.game.repository.InventoryRepository; import org.ezcode.codetest.domain.game.repository.ItemRepository; +import org.ezcode.codetest.domain.game.repository.MatchTokenBucketRepository; import org.ezcode.codetest.domain.game.util.StatUpdateUtil; import org.springframework.stereotype.Service; @@ -30,17 +32,21 @@ public class CharacterStatusDomainService { private final InventoryRepository inventoryRepository; private final ItemRepository itemRepository; private final CharacterEquipService characterLoadService; + private final MatchTokenBucketRepository matchTokenBucketRepository; private final StatUpdateUtil statUpdateUtil; public GameCharacter createGameCharacter(GameCharacter character) { GameCharacter savedCharacter = characterRepository.save(character); + Inventory savedInventory = inventoryRepository.save(new Inventory(savedCharacter)); characterLoadService.equipDefaultItem(savedCharacter, savedInventory, DEFAULT_WEAPON); characterLoadService.equipDefaultItem(savedCharacter, savedInventory, DEFAULT_DEFENCE); characterLoadService.equipDefaultItem(savedCharacter, savedInventory, DEFAULT_ACCESSORY); + matchTokenBucketRepository.save(new GameCharacterMatchTokenBucket(savedCharacter)); + return savedCharacter; } diff --git a/src/main/java/org/ezcode/codetest/domain/game/service/GameEncounterDomainService.java b/src/main/java/org/ezcode/codetest/domain/game/service/GameEncounterDomainService.java index b4bdd1f0..7d77596c 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/service/GameEncounterDomainService.java +++ b/src/main/java/org/ezcode/codetest/domain/game/service/GameEncounterDomainService.java @@ -7,6 +7,7 @@ import org.ezcode.codetest.domain.game.exception.GameExceptionCode; import org.ezcode.codetest.domain.game.model.character.CharacterRealStat; import org.ezcode.codetest.domain.game.model.character.GameCharacter; +import org.ezcode.codetest.domain.game.model.character.GameCharacterMatchTokenBucket; import org.ezcode.codetest.domain.game.model.character.Inventory; import org.ezcode.codetest.domain.game.model.encounter.BattleHistory; import org.ezcode.codetest.domain.game.model.encounter.EncounterChoice; @@ -24,6 +25,7 @@ import org.ezcode.codetest.domain.game.repository.EncounterHistoryRepository; import org.ezcode.codetest.domain.game.repository.GameCharacterRepository; import org.ezcode.codetest.domain.game.repository.InventoryRepository; +import org.ezcode.codetest.domain.game.repository.MatchTokenBucketRepository; import org.ezcode.codetest.domain.game.repository.RandomEncounterRepository; import org.ezcode.codetest.domain.game.strategy.encounter.EncounterStrategy; import org.ezcode.codetest.domain.game.strategy.encounter.EncounterStrategyFactory; @@ -48,6 +50,7 @@ public class GameEncounterDomainService { private final InventoryRepository inventoryRepository; private final RandomEncounterRepository encounterRepository; private final EncounterChoiceRepository choiceRepository; + private final MatchTokenBucketRepository matchTokenBucketRepository; public BattleLog battle(GameCharacter player, GameCharacter opponent) { @@ -100,8 +103,15 @@ public BattleLog battle(GameCharacter player, GameCharacter opponent) { if (!alive) { battleLog.setPlayerWin(attacker == playerContext); - player.earnGold(100L); - battleLog.add("전투 승리보상으로 100 골드가 지급되었습니다."); + + if (attacker == playerContext) { + player.earnGold(100L); + battleLog.add("전투 승리보상으로 100 골드가 지급되었습니다."); + } else { + player.loseGold(25L); + battleLog.add("패배하여 25 골드를 갈취당했습니다."); + } + return battleLog; } @@ -115,8 +125,15 @@ public BattleLog battle(GameCharacter player, GameCharacter opponent) { if (!alive) { battleLog.setPlayerWin(defender == playerContext); - player.earnGold(-25L); - battleLog.add("전투 패배로 25 골드를 갈취당했습니다."); + + if (defender == playerContext) { + player.earnGold(100L); + battleLog.add("전투 승리보상으로 100 골드가 지급되었습니다."); + } else { + player.loseGold(25L); + battleLog.add("패배하여 25 골드를 갈취당했습니다."); + } + return battleLog; } @@ -156,13 +173,23 @@ public List getBattleHistory(GameCharacter character) { return battleHistoryRepository.findByCharacterId(character.getId()); } - public GameCharacter getRandomEnemyCharacter(Long userId) { + public GameCharacter getRandomEnemyCharacter(Long userId, Long playerId) { + + GameCharacterMatchTokenBucket playerTokenBucket = matchTokenBucketRepository.findByCharacterId(playerId) + .orElseThrow(() -> new GameException(GameExceptionCode.PLAYER_TOKEN_BUCKET_NOT_EXISTS)); + + playerTokenBucket.consumeBattleToken(); return characterRepository.findRandomCharacter(userId) .orElseThrow(() -> new GameException(GameExceptionCode.RANDOM_CHARACTER_MATCHING_FAIL)); } - public RandomEncounter getRandomEncounter() { + public RandomEncounter getRandomEncounter(Long playerId) { + + GameCharacterMatchTokenBucket playerTokenBucket = matchTokenBucketRepository.findByCharacterId(playerId) + .orElseThrow(() -> new GameException(GameExceptionCode.PLAYER_TOKEN_BUCKET_NOT_EXISTS)); + + playerTokenBucket.consumeEncounterToken(); List encounters = encounterRepository.findAllEncounters(); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AmbushBanditsBad.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AmbushBanditsBad.java index edca8d68..3cf346f5 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AmbushBanditsBad.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AmbushBanditsBad.java @@ -44,7 +44,7 @@ public void eventHappen( } else { log.add("당신은 도망을 택했지만... 도망치지 못했습니다."); long lostGold = character.getGold() / 2; - character.earnGold(-lostGold); + character.loseGold(lostGold); realStat.applyCritChange(-0.5); log.add("첫 번째 지팡이 타격에 정신이 멍해집니다. 뒤이어 날아든 팬케이크 뒤집개가 머리를 스칩니다."); log.add("‘손주 재우기’라는 말 대신 ‘골드 재우기’가 우선이었던 모양입니다."); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AmbushBanditsGood.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AmbushBanditsGood.java index 0821df19..ddc681da 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AmbushBanditsGood.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AmbushBanditsGood.java @@ -45,7 +45,7 @@ public void eventHappen( log.setIsPositive(true); } else { long lost = character.getGold() / 2; - character.earnGold(-lost); + character.loseGold(lost); realStat.applyDefChange(-1.0); log.add("그러나 결심과는 무색하게 %s(은)는 첫 지팡이에 명치가 눌리고, 두 번째 롤링 핀에 시야가 돌아갑니다.", playerContext.getName()); log.add("누군가는 ‘치료용 허브차’를 권했지만, 사실 그건 다시 맞으라는 신호였습니다."); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AncientRuinsTrap.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AncientRuinsTrap.java index a3718c7b..2cc8f9db 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AncientRuinsTrap.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/AncientRuinsTrap.java @@ -32,7 +32,7 @@ public void eventHappen( log.add("간신히 벽 틈에 매달리긴 했지만... 허리춤의 금화 주머니는 ‘희생정신’을 발휘해 먼저 탈출했습니다."); long lostGold = Math.min(100L, character.getGold()); - character.earnGold(-lostGold); + character.loseGold(lostGold); log.add("아래에선 ‘짤랑짤랑’ 소리가 메아리치고, 당신의 %d골드는 이제 유물과 함께 박제될 예정입니다.".formatted(lostGold)); log.add("%s(은)는 가까스로 올라왔지만, 어쩐지 어깨는 무겁고 주머니는 가볍습니다.", player); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/BossEncounterBad.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/BossEncounterBad.java index b8798ed7..321271fe 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/BossEncounterBad.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/BossEncounterBad.java @@ -27,7 +27,7 @@ public void eventHappen( EncounterLog log ) { CharacterRealStat realStat = character.getRealStat(); - double playerDef = realStat.getDef(); + double playerDef = playerContext.getDef(); int variance = ThreadLocalRandom.current().nextInt(-10, 11); double rawBossAtk = 50 + variance; @@ -40,6 +40,7 @@ public void eventHappen( log.add("당신은 한숨을 내쉬며, 문 쪽으로 도망치려던 발걸음을 거둡니다."); log.add("“그래, 한 대 맞고 죽을 운명이면 그것도 나쁘지 않지.” 라는 생각은 대체 왜 드는 걸까요?"); log.add("골렘이 기지개를 펴듯 팔을 들고, %s(을)를 향해 그대로 내려찍습니다! 피해: %,.1f", player, rawBossAtk); + log.add("※ 남은 체력: %,.1f", playerContext.getHp()); if (!alive) { realStat.applyDefChange(-1.0); log.add("방어 자세? 그런 건 애초에 없었습니다. %s(은)는 벽돌처럼 튕겨 나갑니다.", player); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/GamblingBad.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/GamblingBad.java index 5dc77178..c0a27541 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/GamblingBad.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/GamblingBad.java @@ -21,7 +21,7 @@ public void eventHappen(GameCharacter character, Inventory inventory, CharacterC String player = context.getName(); long lossGold = Math.min(200L, character.getGold()); - character.earnGold(-lossGold); + character.loseGold(lossGold); log.add("%s(은)는 3을 골랐습니다. 주사위는 그 선택을 무시하며, 딴 곳에서 한가롭게 굴러갑니다.", player); log.add("‘3’은커녕 주사위는 ‘3’ 근처에도 가지 않았습니다. 마치 자기 일 아닌 듯 행동하네요."); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/StatSpeed.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/StatSpeed.java index 62f22065..5d2b9af3 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/StatSpeed.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/StatSpeed.java @@ -34,10 +34,13 @@ public void eventHappen(GameCharacter character, Inventory inventory, CharacterC log.add("%s(은)는 ‘별수 있나’ 싶어 병뚜껑을 열고 한 모금 들이켰습니다. 이제 결과는 순전히 운에 맡겨야죠.", player); if (speedChange > 0) { - log.add("갑자기 몸에 힘이 솟구쳤습니다. 움직임이 가벼워지고, ‘이 복도쯤이야 뛰어넘어야지’ 하는 기분이 들었습니다. (스피드 +0.5)"); + log.add("그리고 마시는 순간 당신은 느꼈습니다. ‘오, 개쩌는데?’"); + log.add("물약은 알고 보니 고대 군사 실험 중 폐기된 ‘순간 근육 흥분제’였습니다. 부작용은... 아마 나중에 올 겁니다. (스피드 +0.5)"); log.setIsPositive(true); } else { - log.add("갑자기 몸이 무겁고 둔해졌습니다. ‘이게 축복이라면 난 저주를 택하겠다’라는 생각이 스쳤죠. (스피드 -0.5)"); + log.add("그리고 마시는 순간 당신은 느꼈습니다. ‘아… 이거 망했네.’"); + log.add("물약은 사실 포스트 아포칼립스에서 가장 위험한 그것, ‘만병 통치용 파이프 세척제’였습니다. (스피드 -0.5)"); + log.add("%s(은)는 바닥에 주저앉아 속이 뒤집히는 느낌을 음미합니다. ‘아, 이게 진짜 체험학습이구나’", player); log.setIsPositive(false); } } diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/TreasureCacheFound.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/TreasureCacheFound.java index c23a3b31..bfc3790a 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/TreasureCacheFound.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/TreasureCacheFound.java @@ -50,7 +50,7 @@ public void eventHappen( character.earnGold(200L); log.add("뚜껑을 열자마자, 익숙한 무기. 아마 지난 던전에서도 주웠던 그 녀석이 눈에 들어옵니다."); log.add("고대 상자가 당신에게 묻는 듯합니다. “복붙된 무기는 어때? 대신 골드 200개는 덤이야.”"); - log.add("%s(은)는200 골드를 얻었습니다.", player); + log.add("%s(은)는 200 골드를 얻었습니다.", player); } else { inventory.addItem(item.getItemType(), item.getId()); log.add("그러자 고풍스러운 장식이 새겨진 방어구가 상자 속에서 조심스레 모습을 드러냈습니다."); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/WildBeastsAttack.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/WildBeastsAttack.java index 195b2da1..d1f42622 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/WildBeastsAttack.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/WildBeastsAttack.java @@ -27,23 +27,34 @@ public void eventHappen( EncounterLog log ) { CharacterRealStat real = character.getRealStat(); - double atk = context.getAtk(); - double chance = Math.min(atk / 100.0, 1.0); + String player = context.getName(); - log.add("어둠을 가르며 거대한 포효가 울려퍼집니다. 무성한 수풀을 헤치고 날카로운 송곳니를 드러낸 맹수가 모습을 드러냅니다!"); - log.add(context.getName() + "(은)는 한 치의 망설임도 없이 무기를 쥐고, 맹수와 한 판 승부에 돌입합니다!"); + double atk = context.getAtk(); + double accuracy = context.getAccuracy(); + double chance = Math.min((atk + accuracy) / 2.0 / 100.0, 1.0); + + log.add("%s(은)는 오늘만은 도망치지 않기로 결심했습니다. 물론, 뇌는 도망을 원했고 장기는 반란을 준비했지만요.", player); + log.add("그 선택은 왜였을까요? 사실 그것은 %s의 의지가 아니라, 화면 앞에서 커피를 마시던 취준생의 무심한 마우스 클릭 때문이었습니다.", player); + log.add("울창한 숲 속, 짙은 안개 너머로 굶주린 맹수의 으르렁거림이 귓가를 울립니다."); + log.add("%s(은)는 한숨을 쉬며 무기를 꺼냅니다. 이젠 동물도 말로 설득할 수 없다는 걸 압니다.", player); + log.add("이런저런 잡생각이 스치던 찰나, 돌연변이 늑대가 포효와 함께 돌진해왔고, %s(은)는 반사적으로 무기를 휘둘렀습니다.", player); boolean victory = ThreadLocalRandom.current().nextDouble() < chance; if (victory) { real.applyDefChange(0.5); - log.add("맹수의 덩치에 주눅들지 않고, " + context.getName() + "(은)는 정확한 일격으로 적의 급소를 찔러 쓰러뜨렸습니다!"); - log.add("전투의 긴장 속에서 몸은 더욱 단련되었고, 방어의 자세가 한층 깊어졌습니다. (방어력 +0.5)"); + log.add("그 일격은 생각 이상으로 정확했고, 돌연변이 늑대는 첫 타에 정신을 잃었습니다."); + log.add("%s(은)는 그 사체에서 쓸만한 가죽만 벗겨갑니다.", player); + log.add("싸움은 짧았고, 생존자는 당신뿐이었습니다."); + log.add("누군가는 이걸 ‘자연의 섭리’라 하겠지만, 그냥 구질구질한 생존일 뿐입니다. (방어력 +0.5)"); log.setIsPositive(true); } else { real.applySpeedChange(-0.5); - log.add("맹수의 매서운 발톱이 허공을 가르며 파고듭니다. " + context.getName() + "(은)는 피하지 못하고 몸을 강하게 얻어맞습니다."); - log.add("의식을 간신히 붙잡은 채 숲을 헤매어 빠져나왔지만, 부상의 여파로 몸놀림이 둔해졌습니다. (스피드 -0.5)"); + log.add("하지만 돌연변이 늑대는 마지막 순간 몸을 비틀어 %s의 허벅지를 노렸습니다.", player); + log.add("송곳니가 살을 찢고 들어오며, %s(은)는 반사적으로 뒤로 물러섰지만 이미 늦었습니다.", player); + log.add("순식간의 싸움. 그러나 결과는 당신에게 너무도 길게 남을 것입니다."); + log.add("고통 속에 %s(은)는 비명을 지르며 언덕을 굴러 도망쳤습니다. 다시는 사료 광고를 믿지 않겠다고 다짐하면서.", player); + log.add("스피드는 줄었고, 반사신경은 고장났습니다. 축하합니다—이제 당신은 늑대보다 느린 인간 대표입니다. (스피드 -0.5)"); log.setIsPositive(false); } } diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/WildBeastsEscape.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/WildBeastsEscape.java index f9771c3a..e20fc495 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/WildBeastsEscape.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/encounter/encounterstrategyimpl/WildBeastsEscape.java @@ -27,27 +27,32 @@ public void eventHappen( EncounterLog log ) { CharacterRealStat real = character.getRealStat(); - double speed = context.getSpeed(); - double evasion = context.getEvasion(); + String player = context.getName(); + + double speed = context.getSpeed(); + double evasion = context.getEvasion(); double chance = Math.min((speed + evasion) / 100.0, 1.0); - log.add("울창한 숲 속, 짙은 안개 너머로 굶주린 맹수의 으르렁거림이 귓가를 울립니다."); - log.add(context.getName() + "(은)는 재빠르게 주위를 살피고, 낙엽을 밟지 않도록 조심스럽게 몸을 낮춥니다."); - log.add("숨죽인 채 날카로운 본능에 몸을 맡기고 도망을 시도합니다..."); + log.add("%s(은)는 망설임 끝에, 싸움 대신 도망을 택합니다. 이 선택이 비겁한지 아닌지는 살아남은 다음에나 따져보죠.", player); + log.add("%s(은)는 본능적으로 숨을 죽이고, 낙엽 하나 밟지 않겠다는 각오로 몸을 낮춥니다.", player); + log.add("이런 상황에서 싸우는 건 용기가 아니라 어리석음이죠. 지금 필요한 건 뚜렷한 목적과 빠른 다리뿐입니다."); + log.add("도망—그 위대한 선택을 하며, %s(은)는 숲의 그림자 속으로 뛰어듭니다!", player); boolean escaped = ThreadLocalRandom.current().nextDouble() < chance; if (escaped) { real.applySpeedChange(0.5); - log.add("나뭇가지 사이를 민첩하게 가르며, 마치 숲의 일부처럼 자연스럽게 움직입니다."); - log.add(context.getName() + "(은)는 위기 속에서도 집중력을 잃지 않고, 야생의 위협을 완벽하게 따돌렸습니다! (스피드 +0.5)"); + log.add("다행히도 %s의 다리는 ‘목숨은 소중하다’는 교훈을 몸소 실천했습니다. 이쯤 되면 도망도 특기입니다.", player); + log.add("돌뿌리를 딛고 튕기듯 나아가며, 가지 사이를 슬라럼 타듯 빠져나갑니다."); + log.add("맹수의 포효는 멀어지고, %s(은)는 자신이 살아 있다는 것에 잠시 벅찬 감정을 느낍니다.", player); + log.add("하지만 감동은 금물입니다. 이건 생존이 아니라 단지 다음 참사를 위한 연장일 뿐이니까요. (스피드 +0.5)"); log.setIsPositive(true); } else { real.applyCritChange(-0.5); - log.add("뒤에서 덮쳐오는 기척에 놀라 몸을 틀지만, 맹수의 발톱이 팔을 깊게 긁고 지나갑니다."); - log.add("격통에 휘청이는 사이, " + context.getName() + "(은)는 가까스로 몸을 일으켜 도망치지만 팔의 부상은 크고 깊습니다."); - log.add("팔을 심하게 다쳐 예리한 일격을 날릴 힘과 정밀함을 잃었습니다. (치명타 확률 -0.5)"); - log.add("숨을 헐떡이며 숲을 빠져나온 " + context.getName() + "(은)는, 무력감과 함께 상처를 감싸쥐고 한동안 움직이지 못합니다."); + log.add("하지만 생각과는 다르게 숨소리마저 삼키며 몸을 숨기던 그 순간, —뒤에서 들려온 건 ‘철컥’, 그리고 운명의 통지서."); + log.add("맹수의 발톱이 %s의 팔을 찢고 지나갑니다. 정확히 말하면, 이력서에 새로운 흉터 항목을 추가했죠.", player); + log.add("비틀거리며 도망친 %s(은)는 깨달았습니다. 자연은 언제나 공정한데, 당신만 불합격입니다.", player); + log.add("치명타 감각을 잃었습니다. 그러니까 다음부턴 도망 말고, 보험을 드세요. (치명타 확률 -0.5)"); log.setIsPositive(false); } } diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skilldecorator/DefencePenetrationDecorator.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skilldecorator/DefencePenetrationDecorator.java index d9a889d1..bf0f5d0f 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skilldecorator/DefencePenetrationDecorator.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skilldecorator/DefencePenetrationDecorator.java @@ -42,7 +42,7 @@ public boolean useSkill(CharacterContext attacker, CharacterContext defender, Ba switch (grade) { case LEGENDARY -> log.add( - "[%s] 발동 — 방어력 관통력 +%.0f%% 추가 상승. 방어구는 허울뿐, %s의 공격은 철갑을 꿰뚫습니다. 맞으면 바로 병원행.", + "[%s] 발동 — 방어력 관통력 +%.0f%% 추가 상승. 방어구는 허울뿐, %s의 공격은 철갑을 꿰뚫습니다.", skillName, buff * 100, attacker.getName() ); case UNIQUE -> log.add( diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/ButterflySkill.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/ButterflySkill.java index 49d2fd12..92b685a2 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/ButterflySkill.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/ButterflySkill.java @@ -37,7 +37,7 @@ public boolean useSkill(CharacterContext attacker, CharacterContext defender, Ba double damageDealt = Math.max(rawDamage, 0.0); aliveOverall = defender.playerDamaged(rawDamage + defender.getDef()); log.add(isCrit - ? "%s의 나비의 일격 %d타(치명)! 실루엣만 스친 줄 알았는데 내장이 나왔습니다 — %s에게 %,.1f 피해." + ? "%s의 나비의 일격 %d타(치명)! 실루엣만 스친 줄 알았는데 내장이 삐져 나왔습니다 — %s에게 %,.1f 피해." : "%s의 나비의 일격 %d타 칼짓 하나로 %s의 신체 구조를 재구성했습니다 — %,.1f 피해.", attacker.getName(), i, defender.getName(), damageDealt ); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/DefencePenetrationSkill.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/DefencePenetrationSkill.java index f9ecbd75..e00aa1d6 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/DefencePenetrationSkill.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/DefencePenetrationSkill.java @@ -23,7 +23,7 @@ public boolean useSkill(CharacterContext attacker, double penetrationBuff = defender.getDef() * 0.2; attacker.applyAtkBuff(penetrationBuff); - log.add("%s의 방어력 관통 강화. %s의 20%% 방어력을 추가로 더 무시하고 공격을 시작합니다. 방어구? 그저 속 빈 강정일 뿐입니다.", + log.add("%s의 방어력 관통 강화. %s의 20%% 방어력을 추가로 더 무시하고 공격을 시작합니다.", attacker.getName(), defender.getName()); attacker.consumeActionPoints(); diff --git a/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/NoSkill.java b/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/NoSkill.java index f195eda0..ed9cad1b 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/NoSkill.java +++ b/src/main/java/org/ezcode/codetest/domain/game/strategy/skill/skillstrategyimpl/NoSkill.java @@ -29,7 +29,7 @@ public boolean useSkill(CharacterContext attacker, CharacterContext defender, Ba double hitChance = BASE_HIT_RATE + (attacker.getAccuracy() - defender.getEvasion()); boolean isHit = RNG.nextDouble() * 100 < hitChance; if (!isHit) { - log.add("%s의 공격이 빗나갔습니다. %s(은)는 멀쩡히 웃고 있습니다.", attacker.getName(), defender.getName()); + log.add("%s의 공격은 허공만 베었습니다. %s(은)는 고개를 살짝 돌려 그것을 피했습니다. - 공격 명중 실패", attacker.getName(), defender.getName()); return true; } @@ -47,7 +47,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()); diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketJpaRepository.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketJpaRepository.java new file mode 100644 index 00000000..8529c63a --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketJpaRepository.java @@ -0,0 +1,16 @@ +package org.ezcode.codetest.infrastructure.persistence.repository.game.mysql.character; + +import java.util.Optional; + +import org.ezcode.codetest.domain.game.model.character.GameCharacterMatchTokenBucket; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; + +import jakarta.persistence.LockModeType; + +public interface MatchTokenBucketJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByCharacterId(Long characterId); + +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketRepositoryImpl.java new file mode 100644 index 00000000..7e392803 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketRepositoryImpl.java @@ -0,0 +1,27 @@ +package org.ezcode.codetest.infrastructure.persistence.repository.game.mysql.character; + +import java.util.Optional; + +import org.ezcode.codetest.domain.game.model.character.GameCharacterMatchTokenBucket; +import org.ezcode.codetest.domain.game.repository.MatchTokenBucketRepository; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class MatchTokenBucketRepositoryImpl implements MatchTokenBucketRepository { + + private final MatchTokenBucketJpaRepository matchTokenBucketRepository; + + public GameCharacterMatchTokenBucket save(GameCharacterMatchTokenBucket bucket) { + + return matchTokenBucketRepository.save(bucket); + } + + public Optional findByCharacterId(Long characterId) { + + return matchTokenBucketRepository.findByCharacterId(characterId); + } + +} diff --git a/src/main/java/org/ezcode/codetest/presentation/game/play/GamePlayController.java b/src/main/java/org/ezcode/codetest/presentation/game/play/GamePlayController.java index c26fa11e..ae25abea 100644 --- a/src/main/java/org/ezcode/codetest/presentation/game/play/GamePlayController.java +++ b/src/main/java/org/ezcode/codetest/presentation/game/play/GamePlayController.java @@ -2,6 +2,7 @@ import java.util.List; +import org.ezcode.codetest.application.game.dto.request.encounter.BattleRequest; import org.ezcode.codetest.application.game.dto.request.encounter.EncounterChoiceRequest; import org.ezcode.codetest.application.game.dto.request.item.ItemEquipRequest; import org.ezcode.codetest.application.game.dto.request.item.ItemGamblingRequest; @@ -200,29 +201,13 @@ public ResponseEntity unEquipSkill( } ) @ResponseMessage("정상적으로 배틀이 완료되었습니다.") - @PostMapping("/battles/{enemyId}") + @PostMapping("/battles") public ResponseEntity battle( @AuthenticationPrincipal AuthUser authUser, - @PathVariable Long enemyId + @RequestBody @Valid BattleRequest request ) { return ResponseEntity.status(HttpStatus.OK) - .body(gamePlayUseCase.battle(authUser.getId(), enemyId)); - } - - @Operation( - summary = "무작위 배틀 API", - description = "무작위로 다른 캐릭터와 배틀을 진행합니다.", - responses = { - @ApiResponse(responseCode = "200", description = "배틀 진행 후, 결과 반환") - } - ) - @ResponseMessage("정상적으로 무작위 배틀이 완료되었습니다.") - @PostMapping("/battles") - public ResponseEntity randomBattle( - @AuthenticationPrincipal AuthUser authUser - ) { - return ResponseEntity.status(HttpStatus.OK) - .body(gamePlayUseCase.randomBattle(authUser.getId())); + .body(gamePlayUseCase.battle(authUser.getId(), request)); } @Operation( @@ -251,9 +236,10 @@ public ResponseEntity randomBattleMatching( @ResponseMessage("정상적으로 인카운터 매칭에 성공하였습니다.") @GetMapping("/encounters/matching") public ResponseEntity randomEncounterMatching( + @AuthenticationPrincipal AuthUser authUser ) { return ResponseEntity.status(HttpStatus.OK) - .body(gamePlayUseCase.randomEncounterMatching()); + .body(gamePlayUseCase.randomEncounterMatching(authUser.getId())); } @Operation( @@ -263,15 +249,14 @@ public ResponseEntity randomEncounterMatching( @ApiResponse(responseCode = "200", description = "인카운터 선택지에 대한 결과 반환") } ) - @ResponseMessage("정상적으로 인카운터 선택지가 조회되었습니다.") - @PostMapping("/encounters/{encounterId}") + @ResponseMessage("정상적으로 인카운터 선택지가 결정되었습니다.") + @PostMapping("/encounters/choice") public ResponseEntity encounterChoice( @AuthenticationPrincipal AuthUser authUser, - @PathVariable Long encounterId, @RequestBody @Valid EncounterChoiceRequest request ) { return ResponseEntity.status(HttpStatus.OK) - .body(gamePlayUseCase.encounterChoice(authUser.getId(), encounterId, request)); + .body(gamePlayUseCase.encounterChoice(authUser.getId(), request)); } } From 50364d5a93a24747c6bf449e908fdce78b06fbca Mon Sep 17 00:00:00 2001 From: chat26666 Date: Sat, 21 Jun 2025 15:31:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore=20:=20=EC=98=A4=ED=83=80=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GameCharacterMatchTokenBucket.java | 24 ++++++++++--------- .../MatchTokenBucketRepositoryImpl.java | 2 ++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacterMatchTokenBucket.java b/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacterMatchTokenBucket.java index c60a26b4..291e2933 100644 --- a/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacterMatchTokenBucket.java +++ b/src/main/java/org/ezcode/codetest/domain/game/model/character/GameCharacterMatchTokenBucket.java @@ -6,6 +6,7 @@ import org.ezcode.codetest.domain.game.exception.GameException; import org.ezcode.codetest.domain.game.exception.GameExceptionCode; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -36,9 +37,13 @@ public class GameCharacterMatchTokenBucket { @JoinColumn(name = "game_character_id", nullable = false, unique = true) private GameCharacter character; + @Column(nullable = false) private int remainingBattleMatchTokens; + + @Column(nullable = false) private int remainingEncounterMatchTokens; + @Column(nullable = false) private Instant lastRefillTime; public GameCharacterMatchTokenBucket(GameCharacter character) { @@ -49,15 +54,19 @@ public GameCharacterMatchTokenBucket(GameCharacter character) { lastRefillTime = Instant.now(); } - public void consumeBattleToken() { - + private void refillTokensIfNeeded() { Instant now = Instant.now(); - Duration sinceLastRefill = Duration.between(lastRefillTime, now); + if (sinceLastRefill.compareTo(REFILL_INTERVAL) >= 0) { remainingBattleMatchTokens = MAX_BATTLE_TOKENS; + remainingEncounterMatchTokens = MAX_ENCOUNTER_TOKENS; lastRefillTime = now; } + } + + public void consumeBattleToken() { + refillTokensIfNeeded(); if (remainingBattleMatchTokens <= 0) { throw new GameException(GameExceptionCode.BATTLE_TOKEN_EXHAUSTED); @@ -67,14 +76,7 @@ public void consumeBattleToken() { } public void consumeEncounterToken() { - - Instant now = Instant.now(); - - Duration sinceLastRefill = Duration.between(lastRefillTime, now); - if (sinceLastRefill.compareTo(REFILL_INTERVAL) >= 0) { - remainingBattleMatchTokens = MAX_BATTLE_TOKENS; - lastRefillTime = now; - } + refillTokensIfNeeded(); if (remainingEncounterMatchTokens <= 0) { throw new GameException(GameExceptionCode.ENCOUNTER_TOKEN_EXHAUSTED); diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketRepositoryImpl.java index 7e392803..2154ca6d 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketRepositoryImpl.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/game/mysql/character/MatchTokenBucketRepositoryImpl.java @@ -14,11 +14,13 @@ public class MatchTokenBucketRepositoryImpl implements MatchTokenBucketRepositor private final MatchTokenBucketJpaRepository matchTokenBucketRepository; + @Override public GameCharacterMatchTokenBucket save(GameCharacterMatchTokenBucket bucket) { return matchTokenBucketRepository.save(bucket); } + @Override public Optional findByCharacterId(Long characterId) { return matchTokenBucketRepository.findByCharacterId(characterId);