feat(rogue): balance cards and campaign progression
This commit is contained in:
File diff suppressed because one or more lines are too long
342
data/cards.json
342
data/cards.json
@@ -508,7 +508,7 @@
|
||||
"name": "단검 분사",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "모든 적에게 피해를 4만큼 2번 줍니다.",
|
||||
"aoe": true,
|
||||
@@ -520,10 +520,10 @@
|
||||
"name": "단검 투척",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 9 줍니다. 카드를 1장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"drawUntilHandSize": 6,
|
||||
"draw": 1,
|
||||
"damage": 9,
|
||||
"discard": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
@@ -545,10 +545,9 @@
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 8 줍니다. 약화를 1 부여합니다.",
|
||||
"desc": "피해를 7 줍니다. 약화를 1 부여합니다.",
|
||||
"weak": 1,
|
||||
"damage": 8,
|
||||
"cardPlayedDamage": 2,
|
||||
"damage": 7,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"LeadingStrike": {
|
||||
@@ -557,16 +556,16 @@
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 3 줍니다. 표창을 2장 손으로 가져옵니다.",
|
||||
"desc": "피해를 3 줍니다. 표창을 1장 손으로 가져옵니다.",
|
||||
"damage": 3,
|
||||
"addShiv": 2,
|
||||
"addShiv": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"FollowThrough": {
|
||||
"name": "완수",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "피해를 7 줍니다. 손에 다른 카드가 5장 이상 있다면, 1번 추가로 적중합니다.",
|
||||
"damage": 7,
|
||||
@@ -578,7 +577,7 @@
|
||||
"name": "재주넘기",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "교활. 모든 적에게 피해를 6 줍니다.",
|
||||
"aoe": true,
|
||||
@@ -590,7 +589,7 @@
|
||||
"name": "도탄",
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "normal",
|
||||
"desc": "교활. 무작위 적에게 피해를 3만큼 4번 줍니다.",
|
||||
"damage": 3,
|
||||
@@ -601,12 +600,12 @@
|
||||
},
|
||||
"Prepared": {
|
||||
"name": "예비",
|
||||
"cost": 0,
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "카드를 1장 버리고, 이번 턴에 준 피해만큼 방어를 얻습니다.",
|
||||
"blockPerDamageDealtThisTurn": 1,
|
||||
"desc": "카드를 1장 버리고, 이번 턴에 준 피해의 절반만큼 방어를 얻습니다.",
|
||||
"blockPerDamageDealtThisTurn": 0.5,
|
||||
"discard": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
@@ -618,6 +617,7 @@
|
||||
"rarity": "normal",
|
||||
"desc": "이번 턴 동안 민첩을 2 얻습니다.",
|
||||
"dex": 2,
|
||||
"endTurnDexLoss": 2,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"Deflect": {
|
||||
@@ -634,7 +634,7 @@
|
||||
"name": "검무",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "normal",
|
||||
"desc": "표창을 3장 손으로 가져옵니다. 소멸.",
|
||||
"addShiv": 3,
|
||||
@@ -667,19 +667,19 @@
|
||||
"name": "귀를 찢는 비명",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "이번 턴 동안 모든 적이 힘을 6 잃습니다. 소멸.",
|
||||
"draw": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d",
|
||||
"affectsAllEnemies": true,
|
||||
"enemyStrengthLossThisTurn": 6
|
||||
"enemyStrengthLossThisTurn": 6,
|
||||
"exhaust": true
|
||||
},
|
||||
"CloakAndDagger": {
|
||||
"name": "망토와 단검",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도를 6 얻습니다. 표창을 1장 손으로 가져옵니다.",
|
||||
"block": 6,
|
||||
@@ -690,7 +690,7 @@
|
||||
"name": "맹독",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "중독을 5 부여합니다.",
|
||||
"poison": 5,
|
||||
@@ -700,7 +700,7 @@
|
||||
"name": "뱀 물기",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "보존. 중독을 7 부여합니다.",
|
||||
"poison": 7,
|
||||
@@ -722,12 +722,11 @@
|
||||
"name": "꼬챙이",
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 8만큼 X번 줍니다.",
|
||||
"useAllEnergy": true,
|
||||
"xDamagePerEnergy": 8,
|
||||
"draw": 1,
|
||||
"image": "92a5020c978c46bdabab910598118b86"
|
||||
},
|
||||
"Backstab": {
|
||||
@@ -736,16 +735,17 @@
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"rarity": "unique",
|
||||
"desc": "선천성. 피해를 11 줍니다. 소멸.",
|
||||
"desc": "선천성. 피해를 10 줍니다. 소멸.",
|
||||
"innate": true,
|
||||
"damage": 11,
|
||||
"damage": 10,
|
||||
"exhaust": true,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"PreciseCut": {
|
||||
"name": "정밀한 베기",
|
||||
"cost": 0,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 13 줍니다. 손에 있는 다른 카드 1장당 피해량이 2 감소합니다.",
|
||||
"damage": 13,
|
||||
@@ -756,7 +756,7 @@
|
||||
"name": "마무리",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "이번 턴에 사용한 공격 카드 1장당 피해를 6 줍니다.",
|
||||
"damage": 0,
|
||||
@@ -767,7 +767,7 @@
|
||||
"name": "메멘토 모리",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 9 줍니다. 이번 턴에 버린 카드 1장당 피해량이 4 증가합니다.",
|
||||
"damage": 9,
|
||||
@@ -778,7 +778,7 @@
|
||||
"name": "목 조르기",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 8 줍니다.",
|
||||
"damage": 8,
|
||||
@@ -788,7 +788,7 @@
|
||||
"name": "프레췌",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 스킬 카드 1장당 피해를 5 줍니다.",
|
||||
"damage": 0,
|
||||
@@ -799,7 +799,7 @@
|
||||
"name": "덮치기",
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 12 줍니다. 다음에 사용하는 스킬 카드의 비용이 0 이 됩니다.",
|
||||
"damage": 12,
|
||||
@@ -810,7 +810,7 @@
|
||||
"name": "돌진",
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 10 얻습니다. 피해를 10 줍니다.",
|
||||
"block": 10,
|
||||
@@ -821,7 +821,7 @@
|
||||
"name": "천적",
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 15 줍니다. 다음 턴에, 카드를 2장 뽑습니다.",
|
||||
"nextTurnDraw": 2,
|
||||
@@ -832,7 +832,7 @@
|
||||
"name": "정밀 사격",
|
||||
"cost": 3,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 15 줍니다. 이번 턴에 스킬을 사용할 때마다 비용이 1 감소합니다.",
|
||||
"damage": 15,
|
||||
@@ -843,31 +843,32 @@
|
||||
"name": "계산된 도박",
|
||||
"cost": 0,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 모든 카드를 버린 뒤, 버린 카드의 수만큼 카드를 뽑습니다. 소멸.",
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"discardAll": true,
|
||||
"drawPerDiscarded": 1
|
||||
"drawPerDiscarded": 1,
|
||||
"exhaust": true
|
||||
},
|
||||
"Expose": {
|
||||
"name": "들춰내기",
|
||||
"cost": 0,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "대상 적의 모든 인공물과 방어도를 제거합니다. 취약을 2 부여합니다. 소멸.",
|
||||
"vuln": 2,
|
||||
"image": "0946f69d84464df29b24b94c744c868d",
|
||||
"affectsAllEnemies": true,
|
||||
"removeEnemyBlock": true,
|
||||
"removeEnemyArtifact": true
|
||||
"removeEnemyArtifact": true,
|
||||
"exhaust": true
|
||||
},
|
||||
"HiddenDaggers": {
|
||||
"name": "숨겨진 단검",
|
||||
"name": "숨겨진 표창",
|
||||
"cost": 0,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 2장 버립니다. 표창을 2장 손으로 가져옵니다.",
|
||||
"discard": 2,
|
||||
@@ -889,7 +890,7 @@
|
||||
"name": "곡예",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 3장 뽑습니다. 카드를 1장 버립니다.",
|
||||
"draw": 3,
|
||||
@@ -900,28 +901,18 @@
|
||||
"name": "손기술",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 7 얻습니다. 이번 턴 동안 손에 있는 스킬 카드 1장에 교활을 추가합니다.",
|
||||
"block": 7,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"turnHandSlyCount": 1
|
||||
},
|
||||
"Mirage": {
|
||||
"name": "신기루",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 1장 뽑습니다.",
|
||||
"draw": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Expertise": {
|
||||
"name": "전문성",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "손에 있는 카드가 6장이 될 때까지 카드를 뽑습니다.",
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
@@ -931,7 +922,7 @@
|
||||
"name": "차오르는 독",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "적이 중독을 보유하고 있다면, 중독을 9 부여합니다.",
|
||||
"poison": 9,
|
||||
@@ -942,7 +933,7 @@
|
||||
"name": "흐릿함",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 5 얻습니다. 다음 턴 시작 시 방어도가 사라지지 않습니다.",
|
||||
"block": 5,
|
||||
@@ -953,7 +944,7 @@
|
||||
"name": "다리 걸기",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "약화를 2 부여합니다. 방어도를 11 얻습니다.",
|
||||
"block": 11,
|
||||
@@ -964,7 +955,7 @@
|
||||
"name": "비책",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "표창을 3장 손으로 가져옵니다. 이 카드의 비용이 1 감소합니다.",
|
||||
"addShiv": 3,
|
||||
@@ -975,7 +966,7 @@
|
||||
"name": "탄성 플라스크",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
"desc": "무작위 적에게 중독을 3만큼 3번 부여합니다.",
|
||||
"poison": 3,
|
||||
@@ -987,7 +978,7 @@
|
||||
"name": "반사신경",
|
||||
"cost": 3,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 카드를 2장 뽑습니다.",
|
||||
"draw": 2,
|
||||
@@ -998,7 +989,7 @@
|
||||
"name": "아지랑이",
|
||||
"cost": 3,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 모든 적에게 중독을 4 부여합니다.",
|
||||
"poison": 4,
|
||||
@@ -1009,7 +1000,7 @@
|
||||
"name": "전략가",
|
||||
"cost": 3,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "교활. 에너지를 1 얻습니다.",
|
||||
"gainEnergy": 1,
|
||||
@@ -1020,7 +1011,7 @@
|
||||
"name": "괜찮은 전략",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 종료 시, 카드를 최대 1장까지 보존합니다.",
|
||||
"powerEffect": "retainOne",
|
||||
@@ -1031,7 +1022,7 @@
|
||||
"name": "무한의 검날",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 시작 시, 표창을 1장 손으로 가져옵니다.",
|
||||
"turnStartShiv": 1,
|
||||
@@ -1041,7 +1032,7 @@
|
||||
"name": "발놀림",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "민첩을 2 얻습니다.",
|
||||
"dex": 2,
|
||||
@@ -1049,20 +1040,20 @@
|
||||
},
|
||||
"Outbreak": {
|
||||
"name": "발병",
|
||||
"cost": 1,
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
"desc": "독이 3번 부여될 때마다 모든 적에게 11 피해를 줍니다.",
|
||||
"desc": "독이 3번 부여될 때마다 모든 적에게 6 피해를 줍니다.",
|
||||
"image": "19361e72087946b1888684185b40d935",
|
||||
"poisonApplicationBurstEvery": 3,
|
||||
"poisonApplicationBurstDamage": 11
|
||||
"poisonApplicationBurstDamage": 6
|
||||
},
|
||||
"NoxiousFumes": {
|
||||
"name": "유독 가스",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 시작 시, 모든 적에게 중독을 2 부여합니다.",
|
||||
"poison": 2,
|
||||
@@ -1070,69 +1061,49 @@
|
||||
"value": 2,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"Accuracy": {
|
||||
"name": "정밀",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"rarity": "unique",
|
||||
"desc": "표창의 피해량이 4 증가합니다.",
|
||||
"shivDamageBonus": 4,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"PhantomBlades": {
|
||||
"name": "환영검",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"rarity": "unique",
|
||||
"desc": "표창이 보존을 얻습니다. 매 턴마다 처음으로 사용하는 표창의 피해량이 9 증가합니다.",
|
||||
"shivRetain": true,
|
||||
"firstShivDamageBonus": 9,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Speedster": {
|
||||
"name": "스피드스터",
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
"desc": "내 턴 동안 카드를 뽑을 때마다, 모든 적에게 피해를 2 줍니다.",
|
||||
"desc": "내 턴 동안 카드를 뽑을 때마다, 모든 적에게 피해를 1 줍니다.",
|
||||
"aoe": true,
|
||||
"drawDamage": 2,
|
||||
"drawDamage": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"GrandFinale": {
|
||||
"name": "대단원의 막",
|
||||
"cost": 0,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "뽑을 카드 더미에 카드가 없을 때만 사용할 수 있습니다. 모든 적에게 피해를 60 줍니다.",
|
||||
"desc": "뽑을 카드 더미에 카드가 없을 때만 사용할 수 있습니다. 모든 적에게 피해를 45 줍니다.",
|
||||
"playableWhenDrawPileEmpty": true,
|
||||
"aoe": true,
|
||||
"damage": 60,
|
||||
"damage": 45,
|
||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2"
|
||||
},
|
||||
"Assassinate": {
|
||||
"name": "암살",
|
||||
"cost": 0,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "선천성. 피해를 10 줍니다. 취약을 1 부여합니다. 소멸.",
|
||||
"innate": true,
|
||||
"vuln": 1,
|
||||
"damage": 10,
|
||||
"exhaust": true,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"EchoingSlash": {
|
||||
"name": "메아리 참격",
|
||||
"cost": 1,
|
||||
"cost": 2,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "모든 적에게 피해를 10 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
|
||||
"desc": "모든 적에게 피해를 6 줍니다. 적을 처치할 때마다 이 효과를 반복합니다.",
|
||||
"aoe": true,
|
||||
"damage": 10,
|
||||
"image": "dbdbb1b56ae54672ae68ac6882fff6a2",
|
||||
@@ -1142,18 +1113,19 @@
|
||||
"name": "사냥",
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "legend",
|
||||
"desc": "피해를 10 줍니다. 치명타라면, 카드 보상을 추가로 얻습니다. 소멸.",
|
||||
"damage": 10,
|
||||
"desc": "피해를 10 줍니다. 이 카드로 적을 처치하면 카드 보상을 추가로 얻습니다. 소멸.",
|
||||
"damage": 6,
|
||||
"rewardOnKill": 1,
|
||||
"exhaust": true,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"Murder": {
|
||||
"name": "살해",
|
||||
"cost": 3,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "피해를 1 줍니다. 이번 전투 동안 뽑은 카드 1장당 피해량이 1 증가합니다.",
|
||||
"damage": 1,
|
||||
@@ -1164,29 +1136,18 @@
|
||||
"name": "불쾌",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "에너지를 모두 사용하고, 사용한 에너지만큼 적에게 약화를 부여합니다.",
|
||||
"useAllEnergy": true,
|
||||
"xWeakPerEnergy": 1,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"Adrenaline": {
|
||||
"name": "아드레날린",
|
||||
"cost": 0,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"rarity": "legend",
|
||||
"desc": "에너지를 1 얻습니다. 카드를 2장 뽑습니다. 소멸.",
|
||||
"draw": 2,
|
||||
"gainEnergy": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"StormOfSteel": {
|
||||
"name": "강철의 폭풍",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "assassin",
|
||||
"rarity": "legend",
|
||||
"desc": "손에 있는 모든 카드를 버립니다. 버린 카드의 수만큼 표창을 손으로 가져옵니다.",
|
||||
"discardAll": true,
|
||||
@@ -1197,7 +1158,7 @@
|
||||
"name": "그림자 걸음",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "손에 있는 모든 카드를 버립니다. 다음 턴에, 공격 카드의 피해량이 2배가 됩니다.",
|
||||
"nextTurnAttackMultiplier": 2,
|
||||
@@ -1208,7 +1169,7 @@
|
||||
"name": "그림자 은신",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴 동안 얻는 방어도가 2배가 됩니다.",
|
||||
"blockGainMultiplier": 2,
|
||||
@@ -1218,7 +1179,7 @@
|
||||
"name": "부식성 파도",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴에 카드를 뽑을 때마다, 모든 적에게 중독을 2 부여합니다.",
|
||||
"drawPoison": 2,
|
||||
@@ -1228,7 +1189,7 @@
|
||||
"name": "잉크 칼날",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "hermit",
|
||||
"rarity": "legend",
|
||||
"desc": "잉크투성이 표창을 2장 손으로 가져옵니다.",
|
||||
"addShiv": 2,
|
||||
@@ -1238,28 +1199,30 @@
|
||||
"name": "폭주",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴에 다음에 사용하는 스킬 카드가 1번 추가로 사용됩니다.",
|
||||
"draw": 1,
|
||||
"nextSkillRepeatCount": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"KnifeTrap": {
|
||||
"name": "칼날 함정",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"kind": "Attack",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 1장 뽑습니다.",
|
||||
"draw": 1,
|
||||
"desc": "교활. 모든 적에게 피해를 7 주고 중독을 2 부여합니다.",
|
||||
"aoe": true,
|
||||
"damage": 7,
|
||||
"poison": 2,
|
||||
"sly": true,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"BulletTime": {
|
||||
"name": "불릿 타임",
|
||||
"cost": 3,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "이번 턴 동안 더 이상 카드를 뽑을 수 없습니다. 이번 턴 동안 손에 있는 모든 카드를 비용 없이 사용할 수 있습니다.",
|
||||
"handCostZeroThisTurn": true,
|
||||
@@ -1270,80 +1233,41 @@
|
||||
"name": "악몽",
|
||||
"cost": 3,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 1장 선택합니다. 다음 턴에, 그 카드의 복사본을 3장 손으로 가져옵니다. 소멸.",
|
||||
"nextTurnCopies": 3,
|
||||
"nextTurnSelectHandCard": true,
|
||||
"nextTurnSelectPrompt": "복사할 카드를 선택하세요",
|
||||
"exhaust": true,
|
||||
"image": "0946f69d84464df29b24b94c744c868d"
|
||||
},
|
||||
"ToolsOfTheTrade": {
|
||||
"name": "작업 도구",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "내 턴 시작 시, 카드를 1장 뽑고 카드를 1장 버립니다.",
|
||||
"turnStartDraw": 1,
|
||||
"turnStartDiscard": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"Afterimage": {
|
||||
"name": "잔상",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 사용할 때마다, 방어도를 1 얻습니다.",
|
||||
"image": "0946f69d84464df29b24b94c744c868d",
|
||||
"cardPlayedBlock": 1
|
||||
},
|
||||
"Accelerant": {
|
||||
"name": "촉진제",
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"rarity": "legend",
|
||||
"desc": "적 턴 시작 시 독이 한 번 더 틱합니다.",
|
||||
"image": "19361e72087946b1888684185b40d935",
|
||||
"extraPoisonTicks": 1
|
||||
},
|
||||
"Envenom": {
|
||||
"name": "독 바르기",
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"rarity": "legend",
|
||||
"desc": "공격 카드가 막히지 않은 피해를 줄 때마다, 중독을 1 부여합니다.",
|
||||
"attackPoison": 1,
|
||||
"image": "19361e72087946b1888684185b40d935"
|
||||
},
|
||||
"MasterPlanner": {
|
||||
"name": "설계의 대가",
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "사용한 스킬 카드는 교활해집니다.",
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9",
|
||||
"skillSlyOnPlay": true
|
||||
},
|
||||
"Tracking": {
|
||||
"name": "추적",
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"rarity": "legend",
|
||||
"desc": "약화 상태의 적이 공격 카드로 받는 피해가 2배가 됩니다.",
|
||||
"attackDamageVsWeakMultiplier": 2,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"FanOfKnives": {
|
||||
"name": "칼날 부채",
|
||||
"cost": 2,
|
||||
"kind": "Skill",
|
||||
"class": "rogue",
|
||||
"class": "hermit",
|
||||
"rarity": "legend",
|
||||
"desc": "표창이 이제 모든 적을 대상으로 합니다. 표창을 4장 손으로 가져옵니다.",
|
||||
"addShiv": 4,
|
||||
@@ -1352,9 +1276,9 @@
|
||||
},
|
||||
"SerpentForm": {
|
||||
"name": "구렁이의 형상",
|
||||
"cost": 3,
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "카드를 사용할 때마다, 무작위 적에게 피해를 4 줍니다.",
|
||||
"cardPlayedRandomDamage": 4,
|
||||
@@ -1362,9 +1286,9 @@
|
||||
},
|
||||
"Abrasive": {
|
||||
"name": "연마",
|
||||
"cost": 3,
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "교활. 민첩을 1 얻습니다. 가시를 4 얻습니다.",
|
||||
"dex": 1,
|
||||
@@ -1376,19 +1300,20 @@
|
||||
"name": "진압",
|
||||
"cost": 0,
|
||||
"kind": "Attack",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "선천성. 피해를 11 줍니다. 약화를 3 부여합니다.",
|
||||
"desc": "선천성. 피해를 9 줍니다. 약화를 2 부여합니다. 소멸.",
|
||||
"innate": true,
|
||||
"weak": 3,
|
||||
"damage": 11,
|
||||
"weak": 2,
|
||||
"damage": 9,
|
||||
"exhaust": true,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"WraithForm": {
|
||||
"name": "유령의 형상",
|
||||
"cost": 3,
|
||||
"kind": "Power",
|
||||
"class": "rogue",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
"desc": "불가침을 2 얻습니다. 내 턴 종료 시 민첩을 1 잃습니다.",
|
||||
"intangible": 2,
|
||||
@@ -1421,7 +1346,7 @@
|
||||
},
|
||||
"Steal": {
|
||||
"name": "스틸",
|
||||
"cost": 0,
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
@@ -1437,11 +1362,10 @@
|
||||
"kind": "Skill",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다. 버린 카드마다 카드를 1장 더 뽑고, 표창 1장을 손에 넣습니다.",
|
||||
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다. 버린 카드마다 카드를 1장 더 뽑습니다.",
|
||||
"draw": 1,
|
||||
"discard": 1,
|
||||
"drawPerDiscarded": 1,
|
||||
"addShiv": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"Karma": {
|
||||
@@ -1469,14 +1393,13 @@
|
||||
},
|
||||
"PhysicalTraining": {
|
||||
"name": "피지컬 트레이닝",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "thief",
|
||||
"rarity": "normal",
|
||||
"desc": "힘을 1 얻습니다. 민첩을 1 얻습니다. 방어도를 4 얻습니다.",
|
||||
"desc": "힘을 1 얻습니다. 민첩을 1 얻습니다.",
|
||||
"strength": 1,
|
||||
"dex": 1,
|
||||
"block": 4,
|
||||
"image": "49c8f279bfa64bf3954037f17da0052d"
|
||||
},
|
||||
"ShieldMastery": {
|
||||
@@ -1496,9 +1419,10 @@
|
||||
"kind": "Skill",
|
||||
"class": "thief",
|
||||
"rarity": "unique",
|
||||
"desc": "방어도를 5 얻습니다. 민첩을 1 얻습니다. 이번 턴 동안 손의 다른 스킬 카드 1장이 교활해집니다.",
|
||||
"desc": "방어도를 5 얻습니다. 이번 턴 동안 민첩을 1 얻습니다. 손의 다른 스킬 카드 1장이 교활해집니다.",
|
||||
"block": 5,
|
||||
"dex": 1,
|
||||
"endTurnDexLoss": 1,
|
||||
"turnHandSlyCount": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
@@ -1508,16 +1432,15 @@
|
||||
"kind": "Attack",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
"desc": "무작위 적에게 피해를 2만큼 4번 줍니다. 표창 1장을 손에 넣습니다.",
|
||||
"desc": "무작위 적에게 피해를 2만큼 4번 줍니다.",
|
||||
"damage": 2,
|
||||
"hits": 4,
|
||||
"randomTargetEachHit": true,
|
||||
"addShiv": 1,
|
||||
"image": "1b0f2dc8abd0434990eee1befefcbe0d"
|
||||
},
|
||||
"MuspelHeim": {
|
||||
"name": "무스펠 하임",
|
||||
"cost": 2,
|
||||
"cost": 1,
|
||||
"kind": "Attack",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
@@ -1542,7 +1465,7 @@
|
||||
},
|
||||
"DarkFlare": {
|
||||
"name": "다크 플레어",
|
||||
"cost": 2,
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
@@ -1558,10 +1481,9 @@
|
||||
"kind": "Skill",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다. 버린 카드마다 표창 1장을 손에 넣고, 에너지를 1 얻습니다.",
|
||||
"desc": "카드를 1장 뽑습니다. 카드를 1장 버리고, 에너지를 1 얻습니다.",
|
||||
"draw": 1,
|
||||
"discard": 1,
|
||||
"addShivPerDiscard": true,
|
||||
"gainEnergy": 1,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
@@ -1604,7 +1526,7 @@
|
||||
},
|
||||
"Venom": {
|
||||
"name": "베놈",
|
||||
"cost": 2,
|
||||
"cost": 1,
|
||||
"kind": "Power",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "legend",
|
||||
@@ -1615,7 +1537,7 @@
|
||||
},
|
||||
"Grid": {
|
||||
"name": "그리드",
|
||||
"cost": 1,
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "thiefmaster",
|
||||
"rarity": "unique",
|
||||
@@ -1654,9 +1576,8 @@
|
||||
"kind": "Skill",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "카드를 1장 뽑고, 에너지를 1 얻습니다. 이번 턴 동안 스킬 카드의 비용이 1 감소합니다.",
|
||||
"desc": "카드를 1장 뽑습니다. 이번 턴 동안 스킬 카드의 비용이 1 감소합니다.",
|
||||
"draw": 1,
|
||||
"gainEnergy": 1,
|
||||
"skillCostReductionThisTurn": 1,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
@@ -1666,9 +1587,9 @@
|
||||
"kind": "Power",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "약화 1을 부여합니다. 약화 상태의 적에게 주는 공격 피해가 2배가 됩니다.",
|
||||
"desc": "약화 1을 부여합니다. 약화 상태의 적에게 주는 공격 피해가 1.5배가 됩니다.",
|
||||
"weak": 1,
|
||||
"attackDamageVsWeakMultiplier": 2,
|
||||
"attackDamageVsWeakMultiplier": 1.5,
|
||||
"image": "c1e19219745e44c39ae6ac2f77e347d9"
|
||||
},
|
||||
"ShadowRush": {
|
||||
@@ -1688,9 +1609,10 @@
|
||||
"kind": "Skill",
|
||||
"class": "assassin",
|
||||
"rarity": "normal",
|
||||
"desc": "방어도 4를 얻습니다. 다음 턴에 방어도 4를 얻습니다.",
|
||||
"desc": "방어도 4를 얻습니다. 다음 턴에 방어도 4를 얻습니다. 소멸.",
|
||||
"block": 4,
|
||||
"nextTurnBlock": 4,
|
||||
"exhaust": true,
|
||||
"image": "91a2d1c16cb041549adbf1a0d7b1f37f"
|
||||
},
|
||||
"ShadowBlink": {
|
||||
@@ -1717,7 +1639,7 @@
|
||||
},
|
||||
"JavelinAcceleration": {
|
||||
"name": "자벨린 액셀레이션",
|
||||
"cost": 0,
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"class": "assassin",
|
||||
"rarity": "normal",
|
||||
@@ -1733,17 +1655,17 @@
|
||||
"kind": "Attack",
|
||||
"class": "assassin",
|
||||
"rarity": "unique",
|
||||
"desc": "피해를 7씩 2번 줍니다. 방어도를 무시합니다. 이번 턴 첫 카드라면 피해가 더 강해집니다.",
|
||||
"damage": 7,
|
||||
"desc": "피해를 6씩 2번 줍니다. 방어도를 무시합니다. 이번 턴 첫 카드라면 피해가 더 강해집니다.",
|
||||
"damage": 6,
|
||||
"hits": 2,
|
||||
"pierce": true,
|
||||
"firstCardDamageBonus": 3,
|
||||
"firstCardDamageBonus": 2,
|
||||
"image": "b1360ed0c4b942309d240634b8f36872"
|
||||
},
|
||||
"AssassinPhysicalTraining": {
|
||||
"name": "피지컬 트레이닝",
|
||||
"cost": 1,
|
||||
"kind": "Skill",
|
||||
"cost": 2,
|
||||
"kind": "Power",
|
||||
"class": "assassin",
|
||||
"rarity": "normal",
|
||||
"desc": "힘 1, 민첩 1을 얻고 카드를 1장 뽑습니다.",
|
||||
|
||||
BIN
data/cards.xlsx
BIN
data/cards.xlsx
Binary file not shown.
@@ -1,10 +1,7 @@
|
||||
# 공격 적중 독
|
||||
# 공격 중독
|
||||
|
||||
`attackPoison`은 전투 중 파워가 들고 있는 공용 필드입니다.
|
||||
|
||||
동작:
|
||||
|
||||
- 공격 카드가 실제 피해를 주면 독을 부여합니다.
|
||||
- `aoe` 공격이면 모든 적에게 같은 양의 독을 붙입니다.
|
||||
- `Envenom` 같은 카드가 이 필드를 사용합니다.
|
||||
`attackPoison`은 전투 동안 유지되는 공용 카드 효과 필드입니다.
|
||||
|
||||
- 공격 카드가 실제 체력 피해를 주면 대상에게 지정된 수치만큼 중독을 부여합니다.
|
||||
- 광역 공격은 피해를 받은 각 적에게 중독을 부여합니다.
|
||||
- 현재 Thief Master와 Hermit의 `베놈`이 이 효과를 사용합니다.
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
# Rogue Card Audit
|
||||
# 도적 카드 구성 및 밸런스 기록
|
||||
|
||||
Current status of rogue cards and shared effect hooks.
|
||||
도적 계보의 카드 역할, 직업 이동 금지 대상, 공용 효과 필드를 정리합니다.
|
||||
|
||||
## Implemented
|
||||
## 직업별 컨셉
|
||||
|
||||
`Neutralize`, `SilentStrike`, `Survivor`, `SilentDefend`, `Slice`, `DaggerSpray`, `DaggerThrow`, `PoisonedStab`, `SuckerPunch`, `LeadingStrike`, `FollowThrough`, `FlickFlack`, `Prepared`, `Deflect`, `BladeDance`, `Backflip`, `DodgeAndRoll`, `CloakAndDagger`, `DeadlyPoison`, `Snakebite`, `Untouchable`, `Backstab`, `PreciseCut`, `Finisher`, `MementoMori`, `Flechettes`, `Dash`, `Predator`, `CalculatedGamble`, `HiddenDaggers`, `Acrobatics`, `Blur`, `LegSweep`, `Reflex`, `Haze`, `Tactician`, `WellLaidPlans`, `InfiniteBlades`, `Footwork`, `GrandFinale`, `Adrenaline`, `ShadowStep`, `Assassinate`, `Nightmare`, `ToolsOfTheTrade`, `Afterimage`, `Burst`, `StormOfSteel`, `Abrasive`, `Suppress`, `Expertise`, `Shadowmeld`, `Pounce`, `BouncingFlask`, `Accuracy`, `PhantomBlades`, `Speedster`, `CorrosiveWave`, `Tracking`, `FanOfKnives`, `Strangle`, `Mirage`, `Accelerant`, `MasterPlanner`, `Outbreak`, `EscapePlan`, `HandTrick`, `NoxiousFumes`, `Pinpoint`, `TheHunt`, `Murder`, `Malaise`, `BladeOfInk`, `KnifeTrap`, `BulletTime`, `Envenom`, `SerpentForm`, `WraithForm`, `Skewer`, `Ricochet`, `Anticipate`, `PiercingWail`, `Expose`, `UpMySleeve`, `EchoingSlash`, `BubbleBubble`
|
||||
- `rogue`: 시작 카드, 1차 스킬, 기초 공격·회피·방어
|
||||
- `thief`: 단검 난타, 교활, 버리기, 중독의 시작
|
||||
- `thiefmaster`: 교활·버리기 연계 완성, 광역 난타, 중독 증폭
|
||||
- `assassin`: 표창 생성, 표창 연속 공격, 표창 비용·피해 보조
|
||||
- `hermit`: 표창 보존·광역화·지속 생성 등 표창 빌드 완성
|
||||
|
||||
Shared hooks already in use:
|
||||
Rogue 단계에서도 분기 방향을 미리 경험할 수 있도록 약한 입문 카드를 유지합니다.
|
||||
|
||||
- 중독: `PoisonedStab`
|
||||
- 표창: `LeadingStrike`
|
||||
- 교활: `Untouchable`
|
||||
|
||||
## 스킬 카드 고정
|
||||
|
||||
실제 직업 스킬을 바탕으로 추가한 아래 카드는 다른 차수나 계열로 이동하지 않습니다.
|
||||
|
||||
- Rogue: `DoubleStab`, `LuckySeven`, `Haste`, `DarkSight`, `FlashJump`, `NimbleBody`
|
||||
- Thief: `SavageBlow` 포함 9장
|
||||
- Thief Master: `EdgeCarnival` 포함 11장
|
||||
- Assassin: `ShurikenBurst` 포함 10장
|
||||
- Hermit: `TripleThrow` 포함 9장
|
||||
|
||||
나머지 비스킬 카드는 컨셉에 맞춰 상위 직업으로 이동할 수 있습니다. 상위 직업은 하위 직업 카드를 함께 사용하므로, 이동은 해당 분기의 보상 풀을 제한하는 역할을 합니다.
|
||||
|
||||
## 공용 효과 필드
|
||||
|
||||
- `poison`, `innate`, `playableWhenDrawPileEmpty`
|
||||
- `retain`, `sly`, `discard`, `discardAll`, `addShiv`, `addShivPerDiscard`, `turnStartShiv`, `retainOne`
|
||||
@@ -17,6 +39,32 @@ Shared hooks already in use:
|
||||
- `firstCardDamageBonus`
|
||||
- `drawDamage`, `drawPoison`, `shivDamageBonus`, `firstShivDamageBonus`, `shivRetain`, `shivAoe`, `attackDamageVsWeakMultiplier`, `poisonHits`, `poisonRandomTargets`, `skillSlyOnPlay`, `extraPoisonTicks`, `poisonApplicationBurstEvery`, `poisonApplicationBurstDamage`
|
||||
|
||||
## Open questions
|
||||
## 중복 제거 및 보정
|
||||
|
||||
None at the moment.
|
||||
- 삭제: `Mirage`, `Accuracy`, `PhantomBlades`, `Adrenaline`, `Afterimage`, `Accelerant`, `Envenom`, `Tracking`
|
||||
- 이유: 상위 직업 스킬 카드와 효과가 같거나, 비용 대비 열세라 별도 선택지가 되지 못함
|
||||
- `Anticipate`: 턴 종료 시 얻은 민첩을 잃도록 실제 효과와 설명을 일치시킴
|
||||
- `Backstab`, `Assassinate`, `TheHunt`, `PiercingWail`: 설명에 있던 소멸을 실제 필드에 반영
|
||||
- 2차 지급: Thief `DaggerAcceleration`, Assassin `JavelinAcceleration`
|
||||
- 3차 지급: Thief Master `Venom`, Hermit `SpiritJavelin`
|
||||
|
||||
## 카드 효율 검증
|
||||
|
||||
`node tools/balance/card-efficiency.mjs --runs 1000`으로 도적 계열 카드 전체를 검증합니다.
|
||||
|
||||
- 각 직업의 기준 덱에서 같은 종류의 카드 한 장을 교체하고 동일 시드로 반복 전투합니다.
|
||||
- 승률, 승리 시 체력, 전투 턴을 합친 점수를 같은 직업·희귀도 중앙값과 비교합니다.
|
||||
- 0코스트 에너지 생성, 재사용 가능한 영구 능력치, 저비용 2배 증폭처럼 자동 플레이가 놓치기 쉬운 구조도 별도로 검사합니다.
|
||||
- 교활, 조건부 중독, 카드 보존처럼 플레이 순서 의존성이 큰 효과는 자동 시뮬레이션 하위권만으로 상향하지 않습니다.
|
||||
|
||||
2026-07-01 검증 결과 구조적 위험은 0장입니다. 주요 조정은 `발병`, `메아리 참격`, `진압`, `그리드`, `스피드스터`, `스틸`, 두 계열의 `피지컬 트레이닝`, `마크 오브 어쌔신`, `자벨린 액셀레이션`, `예비`에 반영했습니다.
|
||||
|
||||
## 5섹션 캠페인 검증
|
||||
|
||||
`node tools/balance/rogue-campaign.mjs --runs 5000 --reward-min 5`로 전체 런을 검증합니다.
|
||||
|
||||
- 섹션마다 일반전 4회, 엘리트 1회, 보스 1회를 진행합니다.
|
||||
- 1섹션은 Rogue, 2섹션은 2차 직업, 3~5섹션은 3차 직업 카드 풀을 사용합니다.
|
||||
- 실제 카드 보상 확률, 전직 지급 카드, 시작·획득 유물, 체력 유지와 휴식 회복을 반영합니다.
|
||||
- 몬스터 배율은 `1.00 → 1.075 → 1.15 → 1.30 → 1.45`이며 런타임과 시뮬레이터가 같은 공용 상수를 사용합니다.
|
||||
- 5,000회 결과: Thief Master 완주 2.9%, Hermit 완주 3.6%. 자동 플레이와 일부 공격형 유물 미구현을 감안한 보수적 결과입니다.
|
||||
|
||||
246
tools/balance/card-efficiency.mjs
Normal file
246
tools/balance/card-efficiency.mjs
Normal file
@@ -0,0 +1,246 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
PLAYER_HP,
|
||||
loadData,
|
||||
mulberry32,
|
||||
simulateCombat,
|
||||
} from './sim-balance.mjs';
|
||||
|
||||
const ROGUE_CLASSES = new Set(['rogue', 'thief', 'thiefmaster', 'assassin', 'hermit']);
|
||||
|
||||
const CONTEXT_DECKS = {
|
||||
rogue: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'DoubleStab', 'Backflip',
|
||||
],
|
||||
thief: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'SavageBlow', 'DaggerAcceleration',
|
||||
'DeadlyPoison', 'Acrobatics',
|
||||
],
|
||||
thiefmaster: [
|
||||
'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend',
|
||||
'Survivor', 'SavageBlow', 'DaggerAcceleration', 'DeadlyPoison',
|
||||
'Acrobatics', 'EdgeCarnival', 'PickPocket', 'Venom',
|
||||
],
|
||||
assassin: [
|
||||
'SilentStrike', 'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend', 'SilentDefend',
|
||||
'Neutralize', 'Survivor', 'LeadingStrike', 'BladeDance',
|
||||
'JavelinAcceleration', 'JavelinMastery',
|
||||
],
|
||||
hermit: [
|
||||
'SilentStrike', 'SilentStrike',
|
||||
'SilentDefend', 'SilentDefend',
|
||||
'Survivor', 'LeadingStrike', 'BladeDance', 'JavelinAcceleration',
|
||||
'JavelinMastery', 'TripleThrow', 'SpiritJavelin', 'SkilledJavelin',
|
||||
],
|
||||
};
|
||||
|
||||
const ENCOUNTER_SCALE = {
|
||||
rogue: { hp: 1.9, attack: 1.5 },
|
||||
thief: { hp: 2.2, attack: 1.6 },
|
||||
assassin: { hp: 2.25, attack: 1.65 },
|
||||
thiefmaster: { hp: 2.4, attack: 1.5 },
|
||||
hermit: { hp: 2.6, attack: 1.65 },
|
||||
};
|
||||
|
||||
const median = (values) => {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
};
|
||||
|
||||
function validateContextDecks(cards) {
|
||||
for (const [classId, deck] of Object.entries(CONTEXT_DECKS)) {
|
||||
for (const cardId of deck) {
|
||||
if (!cards[cardId]) throw new Error(`${classId} 효율 기준 덱에 없는 카드: ${cardId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeScore(result) {
|
||||
if (result.draw) return -60;
|
||||
if (!result.win) return -100 - result.turns;
|
||||
return 100 + (result.playerHpRemaining / PLAYER_HP) * 30 - result.turns * 2;
|
||||
}
|
||||
|
||||
function scaledEncounter(data, classId) {
|
||||
const scale = ENCOUNTER_SCALE[classId];
|
||||
return {
|
||||
...data,
|
||||
monsters: data.monsters.map((monster) => ({
|
||||
...monster,
|
||||
maxHp: Math.round(monster.maxHp * scale.hp),
|
||||
intents: monster.intents.map((intent) => intent.kind === 'Attack'
|
||||
? { ...intent, value: Math.round(intent.value * scale.attack) }
|
||||
: { ...intent }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function simulateDeck(baseData, deck, runs, seed, trackedCardId = null) {
|
||||
let wins = 0;
|
||||
let totalTurns = 0;
|
||||
let totalHp = 0;
|
||||
let totalScore = 0;
|
||||
let totalPlays = 0;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const stats = {};
|
||||
const rng = mulberry32((seed + Math.imul(i + 1, 0x9e3779b1)) >>> 0);
|
||||
const result = simulateCombat({ ...baseData, starterDeck: deck }, rng, stats);
|
||||
if (result.win) {
|
||||
wins++;
|
||||
totalHp += result.playerHpRemaining;
|
||||
}
|
||||
totalTurns += result.turns;
|
||||
totalScore += outcomeScore(result);
|
||||
if (trackedCardId && stats[trackedCardId]) totalPlays += stats[trackedCardId].plays;
|
||||
}
|
||||
return {
|
||||
winRate: wins / runs,
|
||||
avgTurns: totalTurns / runs,
|
||||
avgHpOnWin: wins > 0 ? totalHp / wins : 0,
|
||||
score: totalScore / runs,
|
||||
avgPlays: totalPlays / runs,
|
||||
};
|
||||
}
|
||||
|
||||
function replacementIndex(deck, cards, candidate) {
|
||||
const preferredKind = candidate.kind === 'Attack' ? 'Attack' : 'Skill';
|
||||
const preferred = deck.findIndex((id) => cards[id]?.kind === preferredKind);
|
||||
if (preferred >= 0) return preferred;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function structuralRisks(card) {
|
||||
const risks = [];
|
||||
const cost = card.cost || 0;
|
||||
const exhaust = card.exhaust === true;
|
||||
const permanentDex = Math.max(0, (card.dex || 0) - (card.endTurnDexLoss || 0));
|
||||
const permanentStats = (card.strength || 0) + permanentDex + (card.thorns || 0);
|
||||
const generatedCards = (card.addShiv || 0) + (card.addShivPerDiscard ? 1 : 0);
|
||||
|
||||
if (cost === 0 && !exhaust && (card.gainEnergy || 0) > 0) {
|
||||
risks.push('0코스트 비소멸 카드가 에너지를 생성');
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.draw || 0) >= 2 && generatedCards > 0) {
|
||||
risks.push('0코스트 비소멸 카드가 2장 이상 드로우하면서 카드를 생성');
|
||||
}
|
||||
if (card.kind !== 'Power' && !exhaust && permanentStats > 0) {
|
||||
risks.push('재사용 가능한 카드가 영구 능력치를 누적');
|
||||
}
|
||||
if (card.kind === 'Power' && (card.attackDamageVsWeakMultiplier || 0) >= 2 && cost <= 1) {
|
||||
risks.push('저비용 지속 효과가 공격 피해를 2배 이상 증폭');
|
||||
}
|
||||
if ((card.poisonApplicationBurstEvery || 0) > 0) {
|
||||
const burstPerApplication = (card.poisonApplicationBurstDamage || 0) / card.poisonApplicationBurstEvery;
|
||||
if (burstPerApplication > 3 && cost <= 1) {
|
||||
risks.push('저비용 독 누적 폭발 피해가 부여 1회당 3을 초과');
|
||||
}
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.block || 0) + (card.nextTurnBlock || 0) >= 8) {
|
||||
risks.push('0코스트 비소멸 카드의 현재·다음 턴 방어 합계가 8 이상');
|
||||
}
|
||||
if (cost === 0 && !exhaust && (card.blockPerDamageDealtThisTurn || 0) >= 1) {
|
||||
risks.push('0코스트 비소멸 카드가 이번 턴 누적 피해 전부를 방어로 전환');
|
||||
}
|
||||
if (!exhaust && (card.gainEnergy || 0) > 0 && (card.gainEnergy || 0) >= cost && (card.draw || 0) > 0 && generatedCards > 0) {
|
||||
risks.push('에너지 손실 없이 드로우와 카드 생성을 동시에 수행');
|
||||
}
|
||||
if (!exhaust && (card.skillCostReductionThisTurn || 0) > 0 && (card.gainEnergy || 0) > 0 && (card.gainEnergy || 0) >= cost && (card.draw || 0) > 0) {
|
||||
risks.push('에너지 손실 없이 드로우하고 이번 턴 스킬 비용까지 감소');
|
||||
}
|
||||
return risks;
|
||||
}
|
||||
|
||||
export function auditCardEfficiency({ runs = 300, seed = 20260701 } = {}) {
|
||||
const data = loadData();
|
||||
const cards = data.cards;
|
||||
validateContextDecks(cards);
|
||||
|
||||
const baselines = {};
|
||||
for (const [classId, deck] of Object.entries(CONTEXT_DECKS)) {
|
||||
baselines[classId] = simulateDeck(scaledEncounter(data, classId), deck, runs, seed);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const [id, card] of Object.entries(cards)) {
|
||||
if (!ROGUE_CLASSES.has(card.class)) continue;
|
||||
const deck = CONTEXT_DECKS[card.class].slice();
|
||||
deck[replacementIndex(deck, cards, card)] = id;
|
||||
const result = simulateDeck(scaledEncounter(data, card.class), deck, runs, seed, id);
|
||||
rows.push({
|
||||
id,
|
||||
name: card.name,
|
||||
classId: card.class,
|
||||
rarity: card.rarity,
|
||||
kind: card.kind,
|
||||
cost: card.cost || 0,
|
||||
delta: result.score - baselines[card.class].score,
|
||||
...result,
|
||||
risks: structuralRisks(card),
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const peers = rows.filter((other) => other.classId === row.classId && other.rarity === row.rarity);
|
||||
row.peerMedianDelta = median(peers.map((peer) => peer.delta));
|
||||
row.peerGap = row.delta - row.peerMedianDelta;
|
||||
}
|
||||
|
||||
return { runs, seed, baselines, rows };
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatEfficiencyReport(report) {
|
||||
const lines = [];
|
||||
lines.push(`도적 카드 효율 검증: 카드 ${report.rows.length}장, 카드당 ${report.runs}회`);
|
||||
lines.push('기준 덱:');
|
||||
for (const [classId, baseline] of Object.entries(report.baselines)) {
|
||||
lines.push(` ${classId}: 승률 ${formatPercent(baseline.winRate)}, 평균 ${baseline.avgTurns.toFixed(2)}턴, 승리 HP ${baseline.avgHpOnWin.toFixed(1)}`);
|
||||
}
|
||||
|
||||
const risky = report.rows.filter((row) => row.risks.length > 0);
|
||||
lines.push('');
|
||||
lines.push(`구조적 위험 ${risky.length}장:`);
|
||||
for (const row of risky) {
|
||||
lines.push(` ${row.name}(${row.id}, ${row.classId}): ${row.risks.join(' / ')}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('동급 대비 효율 상위:');
|
||||
for (const row of report.rows.slice().sort((a, b) => b.peerGap - a.peerGap).slice(0, 10)) {
|
||||
lines.push(` ${row.name}(${row.id}): 중앙값 대비 +${row.peerGap.toFixed(1)}, 승률 ${formatPercent(row.winRate)}, 평균 사용 ${row.avgPlays.toFixed(2)}회`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('동급 대비 효율 하위:');
|
||||
for (const row of report.rows.slice().sort((a, b) => a.peerGap - b.peerGap).slice(0, 10)) {
|
||||
lines.push(` ${row.name}(${row.id}): 중앙값 대비 ${row.peerGap.toFixed(1)}, 승률 ${formatPercent(row.winRate)}, 평균 사용 ${row.avgPlays.toFixed(2)}회`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let runs = 300;
|
||||
let seed = 20260701;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--runs') runs = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--seed') seed = Number.parseInt(args[++i], 10);
|
||||
}
|
||||
const report = auditCardEfficiency({ runs, seed });
|
||||
console.log(formatEfficiencyReport(report));
|
||||
if (report.rows.some((row) => row.risks.length > 0)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('card-efficiency.mjs')) main();
|
||||
30
tools/balance/card-efficiency.test.mjs
Normal file
30
tools/balance/card-efficiency.test.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { structuralRisks } from './card-efficiency.mjs';
|
||||
|
||||
test('0코스트 에너지 생성 카드를 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 0, kind: 'Skill', gainEnergy: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('에너지를 생성')));
|
||||
});
|
||||
|
||||
test('재사용 가능한 영구 능력치 스킬을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 1, kind: 'Skill', strength: 1, dex: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('영구 능력치')));
|
||||
});
|
||||
|
||||
test('소멸하거나 파워인 능력치 카드는 허용', () => {
|
||||
assert.deepEqual(structuralRisks({ cost: 1, kind: 'Skill', strength: 1, exhaust: true }), []);
|
||||
assert.deepEqual(structuralRisks({ cost: 1, kind: 'Power', dex: 1 }), []);
|
||||
assert.deepEqual(structuralRisks({ cost: 0, kind: 'Skill', dex: 2, endTurnDexLoss: 2 }), []);
|
||||
});
|
||||
|
||||
test('저비용 2배 피해 증폭을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 1, kind: 'Power', attackDamageVsWeakMultiplier: 2 });
|
||||
assert.ok(risks.some((risk) => risk.includes('2배')));
|
||||
});
|
||||
|
||||
test('0코스트 누적 피해 전체 방어 전환을 위험으로 분류', () => {
|
||||
const risks = structuralRisks({ cost: 0, kind: 'Skill', blockPerDamageDealtThisTurn: 1 });
|
||||
assert.ok(risks.some((risk) => risk.includes('누적 피해')));
|
||||
assert.deepEqual(structuralRisks({ cost: 0, kind: 'Skill', blockPerDamageDealtThisTurn: 0.5 }), []);
|
||||
});
|
||||
314
tools/balance/rogue-campaign.mjs
Normal file
314
tools/balance/rogue-campaign.mjs
Normal file
@@ -0,0 +1,314 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mulberry32, rarityForRoll, simulateCombat } from './sim-balance.mjs';
|
||||
import { ACT_DIFFICULTY_MULTIPLIERS } from '../deck/lib/codeblock.mjs';
|
||||
|
||||
const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
|
||||
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
|
||||
const relicsData = JSON.parse(readFileSync('data/relics.json', 'utf8'));
|
||||
|
||||
const PLAYER_MAX_HP = 70;
|
||||
const REST_HEAL = 30;
|
||||
const SECTION_COUNT = 5;
|
||||
const NORMAL_FIGHTS = 4;
|
||||
export const DEFAULT_SECTION_MULTIPLIERS = ACT_DIFFICULTY_MULTIPLIERS;
|
||||
const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump'];
|
||||
const ELITE_POOL = ['mushmom', 'modified_snail'];
|
||||
const BOSS_POOL = ['king_slime', 'slime_boss'];
|
||||
|
||||
const JOBS = {
|
||||
thief: { tier2: 'thief', tier3: 'thiefmaster', tier2Starter: 'DaggerAcceleration', tier3Starter: 'Venom' },
|
||||
assassin: { tier2: 'assassin', tier3: 'hermit', tier2Starter: 'JavelinAcceleration', tier3Starter: 'SpiritJavelin' },
|
||||
};
|
||||
|
||||
const LINEAGES = {
|
||||
rogue: ['rogue'],
|
||||
thief: ['rogue', 'thief'],
|
||||
thiefmaster: ['rogue', 'thief', 'thiefmaster'],
|
||||
assassin: ['rogue', 'assassin'],
|
||||
hermit: ['rogue', 'assassin', 'hermit'],
|
||||
};
|
||||
|
||||
const pick = (rng, values) => values[Math.floor(rng() * values.length)];
|
||||
|
||||
export function campaignJobAtSection(branch, section) {
|
||||
if (section <= 1) return 'rogue';
|
||||
if (section === 2) return JOBS[branch].tier2;
|
||||
return JOBS[branch].tier3;
|
||||
}
|
||||
|
||||
export function playableClassesForJob(job) {
|
||||
return LINEAGES[job] || [job];
|
||||
}
|
||||
|
||||
export function scaleEnemy(enemy, section, rng = () => 0, scaleStep = null) {
|
||||
const multiplier = scaleStep == null
|
||||
? (DEFAULT_SECTION_MULTIPLIERS[section - 1] || DEFAULT_SECTION_MULTIPLIERS.at(-1))
|
||||
: 1 + (section - 1) * scaleStep;
|
||||
const offset = enemy.intents.length > 0 ? Math.floor(rng() * enemy.intents.length) : 0;
|
||||
const rotatedIntents = enemy.intents.map((_, index) => enemy.intents[(index + offset) % enemy.intents.length]);
|
||||
return {
|
||||
...enemy,
|
||||
maxHp: Math.floor(enemy.maxHp * multiplier),
|
||||
intents: rotatedIntents.map((intent) => ({
|
||||
...intent,
|
||||
value: intent.kind === 'Debuff' || intent.value == null
|
||||
? intent.value
|
||||
: Math.floor(intent.value * multiplier),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEncounter(kind, section, rng, scaleStep) {
|
||||
const ids = [];
|
||||
if (kind === 'normal') {
|
||||
const count = 1 + Math.floor(rng() * 3);
|
||||
for (let i = 0; i < count; i++) ids.push(pick(rng, COMBAT_POOL));
|
||||
} else if (kind === 'elite') {
|
||||
ids.push(pick(rng, ELITE_POOL));
|
||||
const extra = Math.floor(rng() * 3);
|
||||
for (let i = 0; i < extra; i++) ids.push(pick(rng, COMBAT_POOL));
|
||||
} else {
|
||||
ids.push(pick(rng, BOSS_POOL));
|
||||
}
|
||||
return ids.map((id) => scaleEnemy(enemiesData.enemies[id], section, rng, scaleStep));
|
||||
}
|
||||
|
||||
function baseCardValue(card) {
|
||||
const hits = card.hits || 1;
|
||||
const targets = card.aoe ? 1.7 : 1;
|
||||
let value = (card.damage || 0) * hits * targets;
|
||||
value += (card.block || 0) + (card.nextTurnBlock || 0) * 0.7;
|
||||
value += (card.poison || 0) * (card.poisonHits || 1) * (card.affectsAllEnemies ? 2 : 1) * 1.5;
|
||||
value += (card.draw || 0) * 4 + (card.gainEnergy || 0) * 5;
|
||||
value += (card.addShiv || 0) * 4;
|
||||
value += (card.strength || 0) * 6 + (card.dex || 0) * 5;
|
||||
value += (card.weak || 0) * 3 + (card.vuln || 0) * 4;
|
||||
value += (card.intangible || 0) * 12;
|
||||
value += (card.turnStartShiv || 0) * 8 + (card.shivDamageBonus || 0) * 4;
|
||||
value += (card.cardPlayedBlock || 0) * 8 + (card.attackPoison || 0) * 8;
|
||||
value += (card.powerEffect ? 7 : 0) + (card.retain ? 2 : 0) + (card.sly ? 3 : 0);
|
||||
value += (card.damagePerDiscardedThisTurn || 0) * 2;
|
||||
value += (card.damagePerAttackPlayedThisTurn || 0) * 2;
|
||||
value += (card.firstShivDamageBonus || 0) * 2;
|
||||
value -= (card.cost || 0) * 5;
|
||||
if (card.exhaust) value -= 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
function branchCardValue(card, branch, deck, id) {
|
||||
let value = baseCardValue(card);
|
||||
if (branch === 'thief') {
|
||||
value += (card.poison || 0) * 1.5 + (card.attackPoison || 0) * 8;
|
||||
value += card.sly ? 5 : 0;
|
||||
value += (card.discard || 0) * 2 + (card.drawPerDiscarded || 0) * 4;
|
||||
value += (card.poisonApplicationBurstDamage || 0) * 1.5;
|
||||
} else {
|
||||
value += (card.addShiv || 0) * 3 + (card.turnStartShiv || 0) * 8;
|
||||
value += (card.shivDamageBonus || 0) * 6 + (card.firstShivDamageBonus || 0) * 3;
|
||||
value += card.shivAoe ? 12 : 0;
|
||||
value += card.shivRetain ? 5 : 0;
|
||||
}
|
||||
const copies = deck.filter((cardId) => cardId === id).length;
|
||||
value -= copies * (card.kind === 'Power' ? 10 : 3);
|
||||
return value;
|
||||
}
|
||||
|
||||
function rewardPool(job) {
|
||||
const classes = new Set(playableClassesForJob(job));
|
||||
return Object.entries(cardsData.cards)
|
||||
.filter(([, card]) => classes.has(card.class) && card.token !== true && card.unplayable !== true);
|
||||
}
|
||||
|
||||
function offerReward(job, branch, deck, rng, minimumValue) {
|
||||
const pool = rewardPool(job);
|
||||
const choices = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const rarity = rarityForRoll(1 + Math.floor(rng() * 100));
|
||||
const bucket = pool.filter(([, card]) => card.rarity === rarity);
|
||||
choices.push(pick(rng, bucket.length > 0 ? bucket : pool));
|
||||
}
|
||||
choices.sort((a, b) => branchCardValue(b[1], branch, deck, b[0]) - branchCardValue(a[1], branch, deck, a[0]));
|
||||
const [id, card] = choices[0];
|
||||
if (branchCardValue(card, branch, deck, id) >= minimumValue) deck.push(id);
|
||||
}
|
||||
|
||||
function relicModifiers(state) {
|
||||
const result = {
|
||||
playerStartBlock: 0,
|
||||
playerStrength: 0,
|
||||
playerThorns: 0,
|
||||
energyBonus: 0,
|
||||
openingDrawBonus: 0,
|
||||
healOnAttack: 0,
|
||||
};
|
||||
for (const id of state.relics) {
|
||||
const relic = relicsData.relics[id];
|
||||
if (!relic) continue;
|
||||
if (relic.hook === 'combatStart' && relic.effect === 'block') result.playerStartBlock += relic.value;
|
||||
else if (relic.hook === 'combatStart' && relic.effect === 'strength') result.playerStrength += relic.value;
|
||||
else if (relic.hook === 'turnStart' && relic.effect === 'energy') result.energyBonus += relic.value;
|
||||
else if (relic.hook === 'combatStart' && relic.effect === 'draw') result.openingDrawBonus += relic.value;
|
||||
else if (relic.effect === 'thorns') result.playerThorns += relic.value;
|
||||
else if (relic.effect === 'healOnAttack') result.healOnAttack += relic.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function healFromRelics(state, hook) {
|
||||
for (const id of state.relics) {
|
||||
const relic = relicsData.relics[id];
|
||||
if (!relic || relic.hook !== hook) continue;
|
||||
if (relic.effect === 'heal') state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
else if (relic.effect === 'healOnWin') state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
else if (relic.effect === 'healIfLow' && state.hp <= state.maxHp * 0.5) state.hp = Math.min(state.maxHp, state.hp + relic.value);
|
||||
}
|
||||
}
|
||||
|
||||
function acquireRelic(state, rng) {
|
||||
const available = relicsData.relicPool.filter((id) => !state.relics.includes(id));
|
||||
if (available.length === 0) return;
|
||||
const id = pick(rng, available);
|
||||
state.relics.push(id);
|
||||
const relic = relicsData.relics[id];
|
||||
if (relic?.effect === 'maxHp') {
|
||||
state.maxHp += relic.value;
|
||||
state.hp += relic.value;
|
||||
}
|
||||
}
|
||||
|
||||
function fight(state, branch, kind, section, rng, options) {
|
||||
const monsters = buildEncounter(kind, section, rng, options.scaleStep);
|
||||
healFromRelics(state, 'combatStart');
|
||||
const result = simulateCombat({
|
||||
cards: cardsData.cards,
|
||||
starterDeck: state.deck,
|
||||
monsters,
|
||||
playerHp: state.hp,
|
||||
playerMaxHp: state.maxHp,
|
||||
smartPlayer: true,
|
||||
...relicModifiers(state),
|
||||
}, rng);
|
||||
state.hp = result.playerHpRemaining;
|
||||
state.turns += result.turns;
|
||||
if (!result.win) return false;
|
||||
healFromRelics(state, 'combatEnd');
|
||||
if (kind !== 'boss') offerReward(state.job, branch, state.deck, rng, options.minimumRewardValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function simulateCampaign(branch, rng, {
|
||||
restHeal = REST_HEAL,
|
||||
sectionHeal = 0,
|
||||
scaleStep = null,
|
||||
minimumRewardValue = 10,
|
||||
} = {}) {
|
||||
if (!JOBS[branch]) throw new Error(`지원하지 않는 도적 분기: ${branch}`);
|
||||
const state = {
|
||||
hp: PLAYER_MAX_HP,
|
||||
maxHp: PLAYER_MAX_HP,
|
||||
deck: cardsData.starterDecks.rogue.slice(),
|
||||
job: 'rogue',
|
||||
turns: 0,
|
||||
sectionCleared: 0,
|
||||
diedAt: '',
|
||||
hpAfterSections: [],
|
||||
relics: [relicsData.startingRelic],
|
||||
};
|
||||
const options = { scaleStep, minimumRewardValue };
|
||||
|
||||
for (let section = 1; section <= SECTION_COUNT; section++) {
|
||||
state.job = campaignJobAtSection(branch, section);
|
||||
for (let fightIndex = 1; fightIndex <= NORMAL_FIGHTS; fightIndex++) {
|
||||
if (!fight(state, branch, 'normal', section, rng, options)) {
|
||||
state.diedAt = `${section}-normal`;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
state.hp = Math.min(state.maxHp, state.hp + restHeal);
|
||||
if (!fight(state, branch, 'elite', section, rng, options)) {
|
||||
state.diedAt = `${section}-elite`;
|
||||
return state;
|
||||
}
|
||||
acquireRelic(state, rng);
|
||||
if (!fight(state, branch, 'boss', section, rng, options)) {
|
||||
state.diedAt = `${section}-boss`;
|
||||
return state;
|
||||
}
|
||||
state.sectionCleared = section;
|
||||
state.hpAfterSections.push(state.hp);
|
||||
if (section === 1) state.deck.push(JOBS[branch].tier2Starter);
|
||||
if (section === 2) state.deck.push(JOBS[branch].tier3Starter);
|
||||
if (section >= 3) acquireRelic(state, rng);
|
||||
if (section < SECTION_COUNT) state.hp = Math.min(state.maxHp, state.hp + sectionHeal);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function runCampaignBatch(branch, runs = 1000, seed = 20260701, options = {}) {
|
||||
const sectionReached = Array(SECTION_COUNT).fill(0);
|
||||
const sectionClears = Array(SECTION_COUNT).fill(0);
|
||||
const deaths = {};
|
||||
let fullClears = 0;
|
||||
let totalDeckSize = 0;
|
||||
let totalFinalHp = 0;
|
||||
let totalTurns = 0;
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const rng = mulberry32((seed + Math.imul(i + 1, 0x9e3779b1)) >>> 0);
|
||||
const result = simulateCampaign(branch, rng, options);
|
||||
for (let section = 0; section < SECTION_COUNT; section++) {
|
||||
if (result.sectionCleared >= section) sectionReached[section]++;
|
||||
if (result.sectionCleared >= section + 1) sectionClears[section]++;
|
||||
}
|
||||
if (result.sectionCleared === SECTION_COUNT) {
|
||||
fullClears++;
|
||||
totalFinalHp += result.hp;
|
||||
}
|
||||
if (result.diedAt) deaths[result.diedAt] = (deaths[result.diedAt] || 0) + 1;
|
||||
totalDeckSize += result.deck.length;
|
||||
totalTurns += result.turns;
|
||||
}
|
||||
return {
|
||||
branch,
|
||||
runs,
|
||||
fullClearRate: fullClears / runs,
|
||||
avgFinalHp: fullClears > 0 ? totalFinalHp / fullClears : 0,
|
||||
avgDeckSize: totalDeckSize / runs,
|
||||
avgTurns: totalTurns / runs,
|
||||
sectionConditionalClearRates: sectionClears.map((clears, index) => sectionReached[index] > 0 ? clears / sectionReached[index] : 0),
|
||||
sectionReachRates: sectionReached.map((reached) => reached / runs),
|
||||
deaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCampaignReport(result) {
|
||||
const lines = [];
|
||||
lines.push(`${result.branch} 캠페인 ${result.runs}회`);
|
||||
lines.push(` 전체 클리어 ${(result.fullClearRate * 100).toFixed(1)}%, 클리어 HP ${result.avgFinalHp.toFixed(1)}, 평균 덱 ${result.avgDeckSize.toFixed(1)}장`);
|
||||
result.sectionConditionalClearRates.forEach((rate, index) => {
|
||||
lines.push(` 섹션 ${index + 1}: 도달 ${(result.sectionReachRates[index] * 100).toFixed(1)}%, 도달자 클리어 ${(rate * 100).toFixed(1)}%`);
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let runs = 1000;
|
||||
let seed = 20260701;
|
||||
let restHeal = REST_HEAL;
|
||||
let sectionHeal = 0;
|
||||
let scaleStep = null;
|
||||
let minimumRewardValue = 10;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--runs') runs = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--seed') seed = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--rest-heal') restHeal = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--section-heal') sectionHeal = Number.parseInt(args[++i], 10);
|
||||
else if (args[i] === '--scale-step') scaleStep = Number.parseFloat(args[++i]);
|
||||
else if (args[i] === '--reward-min') minimumRewardValue = Number.parseFloat(args[++i]);
|
||||
}
|
||||
for (const branch of ['thief', 'assassin']) {
|
||||
console.log(formatCampaignReport(runCampaignBatch(branch, runs, seed, { restHeal, sectionHeal, scaleStep, minimumRewardValue })));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('rogue-campaign.mjs')) main();
|
||||
28
tools/balance/rogue-campaign.test.mjs
Normal file
28
tools/balance/rogue-campaign.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
campaignJobAtSection,
|
||||
playableClassesForJob,
|
||||
scaleEnemy,
|
||||
} from './rogue-campaign.mjs';
|
||||
|
||||
test('도적 전직 시점: 1섹션 Rogue, 2섹션 2차, 3섹션부터 3차', () => {
|
||||
assert.equal(campaignJobAtSection('thief', 1), 'rogue');
|
||||
assert.equal(campaignJobAtSection('thief', 2), 'thief');
|
||||
assert.equal(campaignJobAtSection('thief', 3), 'thiefmaster');
|
||||
assert.equal(campaignJobAtSection('assassin', 2), 'assassin');
|
||||
assert.equal(campaignJobAtSection('assassin', 5), 'hermit');
|
||||
});
|
||||
|
||||
test('3차 직업은 자기 계보 카드만 사용', () => {
|
||||
assert.deepEqual(playableClassesForJob('thiefmaster'), ['rogue', 'thief', 'thiefmaster']);
|
||||
assert.deepEqual(playableClassesForJob('hermit'), ['rogue', 'assassin', 'hermit']);
|
||||
});
|
||||
|
||||
test('섹션 난이도는 3차 이후 더 빠르게 증가', () => {
|
||||
const enemy = { maxHp: 100, intents: [{ kind: 'Attack', value: 10 }, { kind: 'Debuff', value: 2 }] };
|
||||
const scaled = scaleEnemy(enemy, 3, () => 0);
|
||||
assert.equal(scaled.maxHp, 114);
|
||||
assert.equal(scaled.intents[0].value, 11);
|
||||
assert.equal(scaled.intents[1].value, 2);
|
||||
});
|
||||
@@ -130,6 +130,19 @@ export function chooseAction(hand, cards, energy, ctx = {}) {
|
||||
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(effectiveCost(x), 1);
|
||||
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(effectiveCost(x), 1);
|
||||
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
||||
if ((ctx.incomingDamage || 0) > (ctx.currentBlock || 0)) {
|
||||
const defensive = entries.filter((x) => {
|
||||
const card = cards[x.id];
|
||||
return (card.block || 0) > 0 || (card.intangible || 0) > 0 || (card.enemyStrengthLossThisTurn || 0) > 0;
|
||||
});
|
||||
if (defensive.length) {
|
||||
return bestBy(defensive, (x) => {
|
||||
const card = cards[x.id];
|
||||
const protection = (card.block || 0) + (card.intangible || 0) * 15 + (card.enemyStrengthLossThisTurn || 0) * 2;
|
||||
return protection / Math.max(effectiveCost(x), 1);
|
||||
}).i;
|
||||
}
|
||||
}
|
||||
if (powers.length) return powers[0].i;
|
||||
if (attacks.length) return bestBy(attacks, dmgEff).i;
|
||||
if (skills.length) return bestBy(skills, blkEff).i;
|
||||
@@ -154,13 +167,15 @@ function bump(s, cost, dmg, blk) {
|
||||
// 반환: { win, turns, playerHpRemaining, draw? }
|
||||
export function simulateCombat(data, rng, stats) {
|
||||
const { cards, starterDeck, monsters } = data;
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
|
||||
const playerMaxHp = data.playerMaxHp || PLAYER_HP;
|
||||
const startingPlayerHp = Math.min(data.playerHp ?? playerMaxHp, playerMaxHp);
|
||||
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: startingPlayerHp };
|
||||
let drawPile = prepareCombatDrawPile(shuffle(starterDeck, rng), cards);
|
||||
let discard = [];
|
||||
const exhaust = [];
|
||||
let hand = [];
|
||||
let pHp = PLAYER_HP, pBlock = 0;
|
||||
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0, pIntangible = 0;
|
||||
let pHp = startingPlayerHp, pBlock = data.playerStartBlock || 0;
|
||||
let pStr = data.playerStrength || 0, pDex = 0, pThorns = data.playerThorns || 0, pWeak = 0, pVuln = 0, pIntangible = 0;
|
||||
let blockGainMultiplier = 1;
|
||||
let handCostZeroThisTurn = false;
|
||||
let drawDisabledThisTurn = false;
|
||||
@@ -200,6 +215,16 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (!alive.length) return null;
|
||||
return alive[Math.floor(rng() * alive.length)];
|
||||
};
|
||||
const expectedIncomingDamage = () => mob.filter((m) => m.alive).reduce((total, m) => {
|
||||
if (!m.intents || m.intents.length === 0) return total;
|
||||
const expected = m.intents.reduce((sum, intent) => {
|
||||
if (intent.kind !== 'Attack') return sum;
|
||||
let amount = calcEnemyAttack(intent.value, m.str, m.weak, pVuln, enemyStrengthLossThisTurn);
|
||||
if (pIntangible > 0 && amount > 1) amount = 1;
|
||||
return sum + amount;
|
||||
}, 0) / m.intents.length;
|
||||
return total + expected;
|
||||
}, 0);
|
||||
const removeEnemyBlock = (target) => {
|
||||
if (target) target.block = 0;
|
||||
};
|
||||
@@ -308,10 +333,30 @@ export function simulateCombat(data, rng, stats) {
|
||||
pBlock += amount;
|
||||
return amount;
|
||||
}
|
||||
function smartDiscardIndex() {
|
||||
if (hand.length === 0) return -1;
|
||||
if (data.smartPlayer !== true) return hand.length - 1;
|
||||
const ranked = hand.map((id, index) => {
|
||||
const card = cards[id] || {};
|
||||
const isSly = card.sly === true || skillSlyOnPlayCards.has(id) || turnSkillSlyCards.has(id);
|
||||
const utility = (card.damage || 0) * (card.hits || 1)
|
||||
+ (card.block || 0)
|
||||
+ (card.draw || 0) * 4
|
||||
+ (card.addShiv || 0) * 4
|
||||
+ (card.poison || 0) * 2;
|
||||
return { index, isSly, unplayable: card.unplayable === true, tooExpensive: (card.cost || 0) > energy, utility };
|
||||
});
|
||||
ranked.sort((a, b) => Number(b.isSly) - Number(a.isSly)
|
||||
|| Number(b.unplayable) - Number(a.unplayable)
|
||||
|| Number(b.tooExpensive) - Number(a.tooExpensive)
|
||||
|| a.utility - b.utility
|
||||
|| a.index - b.index);
|
||||
return ranked[0].index;
|
||||
}
|
||||
function discardForTurnStart(n) {
|
||||
const cnt = Math.min(n, hand.length);
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
const idx = hand
|
||||
const idx = data.smartPlayer === true ? smartDiscardIndex() : hand
|
||||
.map((id, k) => ({ id, k, card: cards[id] }))
|
||||
.sort((a, b) => {
|
||||
const ac = a.card?.cost || 0;
|
||||
@@ -525,7 +570,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (c.dex) pDex += c.dex;
|
||||
if (c.thorns) pThorns += c.thorns;
|
||||
if (c.selfVuln) pVuln += c.selfVuln;
|
||||
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
|
||||
if (c.heal) pHp = Math.min(pHp + c.heal, playerMaxHp);
|
||||
if (c.gainEnergy) energy += c.gainEnergy;
|
||||
activeKillReward = c.rewardOnKill || 0;
|
||||
if (c.intangible) pIntangible += c.intangible;
|
||||
@@ -588,7 +633,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
} else if (c.discard) {
|
||||
const n = Math.min(c.discard, hand.length);
|
||||
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
|
||||
for (let i = 0; i < n; i++) { discardHandCard(smartDiscardIndex(), true); discarded++; }
|
||||
}
|
||||
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
|
||||
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
|
||||
@@ -642,15 +687,23 @@ export function simulateCombat(data, rng, stats) {
|
||||
for (const entry of nextTurnAddCards) addCardsToHand(entry.cardId, entry.amount);
|
||||
nextTurnAddCards = [];
|
||||
}
|
||||
energy = ENERGY + energyBonus;
|
||||
energy = ENERGY + (data.energyBonus || 0) + energyBonus;
|
||||
const drawBonus = nextTurnDraw + powerTurnDraw;
|
||||
nextTurnDraw = 0;
|
||||
draw(HAND_SIZE + drawBonus);
|
||||
draw(HAND_SIZE + drawBonus + (turns === 1 ? (data.openingDrawBonus || 0) : 0));
|
||||
if (powerTurnDiscard > 0) discardForTurnStart(powerTurnDiscard);
|
||||
while (true) {
|
||||
const alive = aliveList();
|
||||
if (alive.length === 0) break;
|
||||
const idx = chooseAction(hand, cards, energy, { drawPileCount: drawPile.length, nextSkillCostZero, skillCostReductionThisTurn, handCostZeroThisTurn, combatCardCostReduction });
|
||||
const idx = chooseAction(hand, cards, energy, {
|
||||
drawPileCount: drawPile.length,
|
||||
nextSkillCostZero,
|
||||
skillCostReductionThisTurn,
|
||||
handCostZeroThisTurn,
|
||||
combatCardCostReduction,
|
||||
incomingDamage: data.smartPlayer === true ? expectedIncomingDamage() : 0,
|
||||
currentBlock: pBlock,
|
||||
});
|
||||
if (idx < 0) break;
|
||||
const id = hand[idx], c = cards[id];
|
||||
let dmg = 0;
|
||||
@@ -662,6 +715,9 @@ export function simulateCombat(data, rng, stats) {
|
||||
const finalCost = c.useAllEnergy === true ? cost : Math.max(0, cost - combatReduction);
|
||||
energy -= finalCost;
|
||||
resolveCardEffects(id, c, finalCost);
|
||||
if (c.kind === 'Attack' && (data.healOnAttack || 0) > 0) {
|
||||
pHp = Math.min(playerMaxHp, pHp + data.healOnAttack);
|
||||
}
|
||||
const playedBlock = powerFieldTotal('cardPlayedBlock');
|
||||
if (playedBlock > 0) addBlock(playedBlock);
|
||||
if (skillRepeat > 0) {
|
||||
|
||||
@@ -121,6 +121,14 @@ test('chooseAction: 공격 없으면 스킬 선택', () => {
|
||||
assert.equal(idx, 0);
|
||||
});
|
||||
|
||||
test('chooseAction: 예상 피해가 남으면 방어 카드를 우선 선택', () => {
|
||||
const cards = {
|
||||
Hit: { kind: 'Attack', cost: 1, damage: 12 },
|
||||
Guard: { kind: 'Skill', cost: 1, block: 8 },
|
||||
};
|
||||
assert.equal(chooseAction(['Hit', 'Guard'], cards, 1, { incomingDamage: 8, currentBlock: 0 }), 1);
|
||||
});
|
||||
|
||||
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
|
||||
const idx = chooseAction(['Bash'], CARDS, 1);
|
||||
assert.equal(idx, -1);
|
||||
@@ -220,6 +228,21 @@ test('simulateCombat: 복합 카드(공격+방어) 블록이 적 공격을 흡
|
||||
assert.equal(r.playerHpRemaining, 80);
|
||||
});
|
||||
|
||||
test('simulateCombat: 캠페인 시작 체력과 유물 전투 보너스를 반영', () => {
|
||||
const data = {
|
||||
cards: { Guard: { name: 'Guard', cost: 1, kind: 'Skill', block: 1 } },
|
||||
starterDeck: ['Guard'],
|
||||
monsters: [{ name: 'Dummy', maxHp: 1, intents: [{ kind: 'Attack', value: 1 }] }],
|
||||
playerHp: 37,
|
||||
playerMaxHp: 70,
|
||||
playerStartBlock: 6,
|
||||
energyBonus: 1,
|
||||
openingDrawBonus: 2,
|
||||
};
|
||||
const result = simulateCombat(data, mulberry32(3));
|
||||
assert.ok(result.playerHpRemaining <= 37);
|
||||
});
|
||||
|
||||
test('calcAttack: 힘·약화·취약 공식 (Lua CalcPlayerAttack·DealDamageToTarget 동기화)', () => {
|
||||
assert.equal(calcAttack(6, 0, 0, 0), 6); // 기본
|
||||
assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, ACT_DIFFICULTY_MULTIPLIERS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
|
||||
import { CARDS, ENEMIES, CLASSES, JOBS, JOB_META, CLASS_GROUPS, CLASS_LINEAGES, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaClassGroupsTable, luaClassLineagesTable, luaJobMetaTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
|
||||
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
|
||||
|
||||
@@ -211,7 +211,8 @@ end
|
||||
if #chosen == 0 then takeFrom(g, 1) end
|
||||
if #chosen == 0 then takeFrom("combat", 1) end
|
||||
table.sort(chosen, function(a, b) return a.x < b.x end)
|
||||
local mult = 1 + (self.Floor - 1) * 0.45
|
||||
local actMultipliers = { ${ACT_DIFFICULTY_MULTIPLIERS.join(', ')} }
|
||||
local mult = actMultipliers[self.Floor] or actMultipliers[#actMultipliers]
|
||||
if g == "elite" or g == "boss" then
|
||||
mult = mult + self:AscEliteBonus()
|
||||
end
|
||||
|
||||
@@ -54,7 +54,8 @@ const REST_HEAL = 30;
|
||||
const RELIC_PRICE = 60;
|
||||
const ACT_COUNT = 5;
|
||||
const ACT_MAPS = ['map01', 'map02', 'map03', 'map04', 'map05'];
|
||||
const ACT_DIFFICULTY_MULTIPLIERS = [1, 1.075, 1.15, 1.3, 1.45];
|
||||
const LOBBY_MAP = 'lobby';
|
||||
const LOBBY_SPAWN = 'Vector3(-5, 0.03, 0)'; // 정찰: map01 지면 좌측
|
||||
|
||||
export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN };
|
||||
export { prop, method, codeblock, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, ACT_DIFFICULTY_MULTIPLIERS, LOBBY_MAP, LOBBY_SPAWN };
|
||||
|
||||
@@ -29,14 +29,14 @@ const JOBS = {
|
||||
{ id: 'cleric', name: '클레릭', desc: '회복·축복 특화\n힐 · 블레스\n홀리 애로우', starter: 'Heal', tier: 2, parent: 'magician' },
|
||||
],
|
||||
rogue: [
|
||||
{ id: 'assassin', name: 'Assassin', desc: '표창 중심 전직\n단일 화력과 독 압박\n빠른 마무리', starter: 'DeadlyPoison', tier: 2, parent: 'rogue' },
|
||||
{ id: 'thief', name: 'Thief', desc: '단검 중심 전직\n드로우와 운영 강화\n빠른 연계', starter: 'Acrobatics', tier: 2, parent: 'rogue' },
|
||||
{ id: 'assassin', name: 'Assassin', desc: '표창 중심 전직\n표창 생성과 연속 공격\n빠른 마무리', starter: 'JavelinAcceleration', tier: 2, parent: 'rogue' },
|
||||
{ id: 'thief', name: 'Thief', desc: '단검 중심 전직\n드로우와 운영 강화\n빠른 연계', starter: 'DaggerAcceleration', tier: 2, parent: 'rogue' },
|
||||
],
|
||||
assassin: [
|
||||
{ id: 'hermit', name: 'Hermit', desc: 'Assassin의 3차 전직\n표창과 독 운영 심화\n누적 압박 강화', starter: 'NoxiousFumes', tier: 3, parent: 'assassin' },
|
||||
{ id: 'hermit', name: 'Hermit', desc: 'Assassin의 3차 전직\n표창 생성과 강화 심화\n연속 공격 완성', starter: 'SpiritJavelin', tier: 3, parent: 'assassin' },
|
||||
],
|
||||
thief: [
|
||||
{ id: 'thiefmaster', name: 'Thief Master', desc: 'Thief의 3차 전직\n단검 운영 심화\n드로우와 템포 강화', starter: 'ToolsOfTheTrade', tier: 3, parent: 'thief' },
|
||||
{ id: 'thiefmaster', name: 'Thief Master', desc: 'Thief의 3차 전직\n단검·교활·중독 심화\n연계 운영 완성', starter: 'Venom', tier: 3, parent: 'thief' },
|
||||
],
|
||||
};
|
||||
for (const [cls, jobs] of Object.entries(JOBS)) {
|
||||
|
||||
Reference in New Issue
Block a user