10 Commits

Author SHA1 Message Date
b796ffbbaf chore: 산출물 재생성 — UI 버그수정 6건 반영(SlayDeckController)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xhLoQbJvQYL65kBtDNDTy
2026-07-09 13:16:21 +09:00
84dc340193 fix(ui): 코드리뷰 발견 UI 버그 6건 수정
1. [높음] DealDamageToTarget에 ShowDmgPop 추가 — 단일/랜덤 공격 데미지
   팝업 누락 해소(팝업 붙이던 PlayAttackFx는 죽은코드). 중복될 물약
   ShowDmgPop 제거.
2. [중간] hand.mjs 다단 랜덤공격 total/hitN을 math.floor로 — 소수 HP →
   render %d 크래시(akabeko/penNib 조건) 방지. 공통케이스 값 불변(=perHit).
3. [높음] ShowShop이 미보유 유물만 제시 — 상점 중복 유물 판매→스택 방지.
   빈 슬롯 숨김·빈 유물 구매 가드 포함.
4. [중간] PickReward 스킵 시에도 BonusRewardScreens 소비 — 보상 건너뛰기
   시 남은 보너스 보상 전량 소실 방지.
5. [낮음] RenderAllDeck: CodexMode면 제목 "카드 도감"으로 — 도감 제목
   오표기(전사 전체 덱) 수정.
6. [낮음] OpenAllDeck에 CodexMode 리셋 추가(opener 간 비대칭 해소).

전 카테고리 검증: cbgap GAP 0·cbprops 0·cardkinds 정합·테스트 105/105
(sim-balance 회귀 없음). §6 미러 무영향.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xhLoQbJvQYL65kBtDNDTy
2026-07-09 13:16:20 +09:00
30c73c9899 Merge pull request 'feat(monster): 적 종별 모델(프리팹) + 스테이지 로스터 배치' (#107) from feature/monster-models into main 2026-07-03 01:34:26 +09:00
ffcec649fa chore: 산출물 재생성 — map01~05 몬스터 인스턴스 entry_id 수정 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xhLoQbJvQYL65kBtDNDTy
2026-07-03 01:18:06 +09:00
3b678e35d1 fix(monster): 맵 인스턴스 origin.entry_id를 modelId와 일치시켜 MissingModel 해소
메이커가 엔티티의 원본 모델을 origin.entry_id로 해석하는데, entry_id를
enemyId("junior_bugi")로 넣어 model://monster-junior_bugi(실제 EntryKey)와
불일치 → [LEA-3028] MissingModel로 전 몬스터 미표시. entry_id를
modelEntryId(=modelId="monster-<id>")로 수정. 메이커 인게임 검증: 전투에서
전 종 정상 렌더(stump=1110101도 나무토막 확인).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xhLoQbJvQYL65kBtDNDTy
2026-07-03 01:18:06 +09:00
47e954266c Merge pull request 'feat: 도적 밸런스 정리 및 전사 승급 계보 추가' (#108) from codex/rogue-campaign-balance into main
Reviewed-on: https://gitea.gahusb.synology.me/gahusb/maplecontest/pulls/108
2026-07-03 00:13:13 +09:00
8b4ece766a feat(warrior): add warrior promotion lineage 2026-07-02 23:47:01 +09:00
5297922f99 Merge pull request 'feat: 도적 직업 카드 및 5섹션 진행 밸런스 조정' (#106) from codex/rogue-campaign-balance into main
Reviewed-on: https://gitea.gahusb.synology.me/gahusb/maplecontest/pulls/106
2026-07-01 22:56:17 +09:00
e3a75c33a3 feat(rogue): localize custom card names 2026-07-01 22:53:54 +09:00
0a040837d9 feat(rogue): balance cards and campaign progression 2026-07-01 22:36:49 +09:00
30 changed files with 1263 additions and 377 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,10 +1,7 @@
# 공격 적중
# 공격
`attackPoison`은 전투 중 파워가 들고 있는 공용 필드입니다.
동작:
- 공격 카드가 실제 피해를 주면 독을 부여합니다.
- `aoe` 공격이면 모든 적에게 같은 양의 독을 붙입니다.
- `Envenom` 같은 카드가 이 필드를 사용합니다.
`attackPoison`은 전투 동안 유지되는 공용 카드 효과 필드입니다.
- 공격 카드가 실제 체력 피해를 주면 대상에게 지정된 수치만큼 중독을 부여합니다.
- 광역 공격은 피해를 받은 각 적에게 중독을 부여합니다.
- 현재 Thief Master와 Hermit의 `베놈`이 이 효과를 사용합니다.

View File

@@ -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,34 @@ 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장입니다. 주요 조정은 `독맥 터뜨리기`, `메아리 칼자국`, `소리 없는 제압`, `그리드`, `그림자 속도전`, `스틸`, 두 계열의 `피지컬 트레이닝`, `마크 오브 어쌔신`, `자벨린 액셀레이션`, `비장의 패`에 반영했습니다.
비스킬 카드 78장의 메이플풍 표시 이름은 `docs/rogue-card-names.md`에서 관리합니다. 메이플 원본 스킬 카드 45장의 이름은 변경하지 않습니다.
## 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%. 자동 플레이와 일부 공격형 유물 미구현을 감안한 보수적 결과입니다.

97
docs/rogue-card-names.md Normal file
View File

@@ -0,0 +1,97 @@
# 도적 비스킬 카드 이름
메이플스토리 원본 스킬을 바탕으로 만든 카드 45장은 이름을 고정합니다.
아래 78장은 다른 직업의 스킬명을 점유하지 않도록 도적 계열의 독자적인 이름으로 변경했습니다.
## Rogue
- `Neutralize`: 무력화 -> 커닝식 견제
- `SilentStrike`: 타격 -> 초보 도적의 칼끝
- `Survivor`: 생존자 -> 골목길 생존술
- `SilentDefend`: 수비 -> 낡은 가죽 방패
- `Slice`: 칼질 -> 짧은 단검질
- `PoisonedStab`: 독 찌르기 -> 초록 독단검
- `SuckerPunch`: 불의의 일격 -> 골목 기습
- `LeadingStrike`: 선제 타격 -> 초보 표창 던지기
- `Anticipate`: 예측 -> 럭키 예감
- `Deflect`: 튕겨내기 -> 단검 쳐내기
- `Backflip`: 공중제비 -> 커닝 곡예
- `DodgeAndRoll`: 구르기 -> 골목 구르기
- `Untouchable`: 범접 불가 -> 연막 속 숨기
- `Backstab`: 배신 -> 그림자 등찌르기
- `EscapePlan`: 탈출구 -> 비상용 연막탄
## Thief
- `DaggerSpray`: 단검 분사 -> 단검비
- `DaggerThrow`: 단검 투척 -> 비도 투척
- `FollowThrough`: 완수 -> 연달아 찌르기
- `FlickFlack`: 재주넘기 -> 커닝 난무
- `Prepared`: 예비 -> 비장의 패
- `PiercingWail`: 귀를 찢는 비명 -> 골목의 살기
- `DeadlyPoison`: 맹독 -> 맹독 조제
- `Snakebite`: 뱀 물기 -> 독니 단검
- `PreciseCut`: 정밀한 베기 -> 급소 절개
- `Finisher`: 마무리 -> 마지막 칼끝
- `MementoMori`: 메멘토 모리 -> 사신의 장부
- `Strangle`: 목 조르기 -> 그림자 올가미
- `Dash`: 돌진 -> 뒷골목 돌파
- `CalculatedGamble`: 계산된 도박 -> 메소 건 승부
- `Expose`: 들춰내기 -> 약점 들추기
- `Acrobatics`: 곡예 -> 지붕 위 곡예
- `HandTrick`: 손기술 -> 재빠른 손놀림
- `Expertise`: 전문성 -> 노련한 단검술
- `BubbleBubble`: 차오르는 독 -> 독액 농축
- `Blur`: 흐릿함 -> 흐린 잔영
- `LegSweep`: 다리 걸기 -> 발목 베기
- `Reflex`: 반사신경 -> 찰나의 반응
- `Tactician`: 전략가 -> 골목길 책략
- `WellLaidPlans`: 괜찮은 전략 -> 빈틈없는 작전
- `Footwork`: 발놀림 -> 사뿐한 발놀림
- `NoxiousFumes`: 유독 가스 -> 숨막히는 독연기
## Thief Master
- `BouncingFlask`: 탄성 플라스크 -> 통통 독병
- `Haze`: 아지랑이 -> 보랏빛 독연기
- `Outbreak`: 발병 -> 독맥 터뜨리기
- `Speedster`: 스피드스터 -> 그림자 속도전
- `GrandFinale`: 대단원의 막 -> 커닝의 대단원
- `Assassinate`: 암살 -> 어둠 속 급소
- `EchoingSlash`: 메아리 참격 -> 메아리 칼자국
- `Murder`: 살해 -> 쌓여가는 살의
- `Malaise`: 불쾌 -> 기운 빼는 독
- `ShadowStep`: 그림자 걸음 -> 그림자 발자국
- `Shadowmeld`: 그림자 은신 -> 연막 속 은신
- `CorrosiveWave`: 부식성 파도 -> 부식 독물결
- `Burst`: 폭주 -> 연속 술수
- `KnifeTrap`: 칼날 함정 -> 숨은 칼날덫
- `BulletTime`: 불릿 타임 -> 멈춘 듯한 순간
- `Nightmare`: 악몽 -> 검은 꿈
- `ToolsOfTheTrade`: 작업 도구 -> 도적의 연장통
- `MasterPlanner`: 설계의 대가 -> 작전의 달인
- `SerpentForm`: 구렁이의 형상 -> 독사의 몸놀림
- `Abrasive`: 연마 -> 거친 숫돌질
- `Suppress`: 진압 -> 소리 없는 제압
- `WraithForm`: 유령의 형상 -> 유령 같은 몸놀림
## Assassin
- `Ricochet`: 도탄 -> 통통 튀는 표창
- `BladeDance`: 검무 -> 표창 별무리
- `CloakAndDagger`: 망토와 단검 -> 망토 속 별
- `Skewer`: 꼬챙이 -> 꿰뚫는 표창
- `Flechettes`: 프레췌 -> 표창 셈법
- `Pounce`: 덮치기 -> 어둠을 가르는 도약
- `Predator`: 천적 -> 표창 끝의 추격
- `Pinpoint`: 정밀 사격 -> 한 점 겨냥
- `HiddenDaggers`: 숨겨진 표창 -> 숨겨둔 표창
- `UpMySleeve`: 비책 -> 소매 속 표창
- `InfiniteBlades`: 무한의 검날 -> 끝없는 표창통
- `TheHunt`: 사냥 -> 커닝 현상금
- `StormOfSteel`: 강철의 폭풍 -> 쇠별 폭풍
## Hermit
- `BladeOfInk`: 잉크 칼날 -> 먹빛 표창
- `FanOfKnives`: 칼날 부채 -> 사방 표창비

View File

@@ -6468,7 +6468,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "junior_bugi",
"entry_id": "monster-junior_bugi",
"sub_entity_id": null,
"root_entity_id": "000005dc-0000-4000-8000-0000000005dc",
"replaced_model_id": null
@@ -6620,7 +6620,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "kapa_drake",
"entry_id": "monster-kapa_drake",
"sub_entity_id": null,
"root_entity_id": "000005dd-0000-4000-8000-0000000005dd",
"replaced_model_id": null
@@ -6772,7 +6772,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "junior_neki",
"entry_id": "monster-junior_neki",
"sub_entity_id": null,
"root_entity_id": "000005de-0000-4000-8000-0000000005de",
"replaced_model_id": null
@@ -6924,7 +6924,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "octopus",
"entry_id": "monster-octopus",
"sub_entity_id": null,
"root_entity_id": "000005df-0000-4000-8000-0000000005df",
"replaced_model_id": null
@@ -7076,7 +7076,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "green_mushroom",
"entry_id": "monster-green_mushroom",
"sub_entity_id": null,
"root_entity_id": "000005e0-0000-4000-8000-0000000005e0",
"replaced_model_id": null
@@ -7228,7 +7228,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "orange_mushroom",
"entry_id": "monster-orange_mushroom",
"sub_entity_id": null,
"root_entity_id": "000005e1-0000-4000-8000-0000000005e1",
"replaced_model_id": null
@@ -7382,7 +7382,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "dile",
"entry_id": "monster-dile",
"sub_entity_id": null,
"root_entity_id": "000005e2-0000-4000-8000-0000000005e2",
"replaced_model_id": null
@@ -7536,7 +7536,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "mano",
"entry_id": "monster-mano",
"sub_entity_id": null,
"root_entity_id": "000005e3-0000-4000-8000-0000000005e3",
"replaced_model_id": null
@@ -7690,7 +7690,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "king_slime",
"entry_id": "monster-king_slime",
"sub_entity_id": null,
"root_entity_id": "000005e4-0000-4000-8000-0000000005e4",
"replaced_model_id": null

View File

@@ -6379,7 +6379,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "pig",
"entry_id": "monster-pig",
"sub_entity_id": null,
"root_entity_id": "000009c4-0000-4000-8000-0000000009c4",
"replaced_model_id": null
@@ -6533,7 +6533,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "green_mushroom",
"entry_id": "monster-green_mushroom",
"sub_entity_id": null,
"root_entity_id": "000009c5-0000-4000-8000-0000000009c5",
"replaced_model_id": null
@@ -6685,7 +6685,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "blue_mushroom",
"entry_id": "monster-blue_mushroom",
"sub_entity_id": null,
"root_entity_id": "000009c6-0000-4000-8000-0000000009c6",
"replaced_model_id": null
@@ -6839,7 +6839,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "orange_mushroom",
"entry_id": "monster-orange_mushroom",
"sub_entity_id": null,
"root_entity_id": "000009c7-0000-4000-8000-0000000009c7",
"replaced_model_id": null
@@ -6993,7 +6993,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "slime",
"entry_id": "monster-slime",
"sub_entity_id": null,
"root_entity_id": "000009c8-0000-4000-8000-0000000009c8",
"replaced_model_id": null
@@ -7147,7 +7147,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "mushmom",
"entry_id": "monster-mushmom",
"sub_entity_id": null,
"root_entity_id": "000009c9-0000-4000-8000-0000000009c9",
"replaced_model_id": null
@@ -7302,7 +7302,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "slime_boss",
"entry_id": "monster-slime_boss",
"sub_entity_id": null,
"root_entity_id": "000009ca-0000-4000-8000-0000000009ca",
"replaced_model_id": null

View File

@@ -6379,7 +6379,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "octopus",
"entry_id": "monster-octopus",
"sub_entity_id": null,
"root_entity_id": "00000dac-0000-4000-8000-000000000dac",
"replaced_model_id": null
@@ -6531,7 +6531,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "junior_neki",
"entry_id": "monster-junior_neki",
"sub_entity_id": null,
"root_entity_id": "00000dad-0000-4000-8000-000000000dad",
"replaced_model_id": null
@@ -6683,7 +6683,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "junior_bugi",
"entry_id": "monster-junior_bugi",
"sub_entity_id": null,
"root_entity_id": "00000dae-0000-4000-8000-000000000dae",
"replaced_model_id": null
@@ -6835,7 +6835,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "slime",
"entry_id": "monster-slime",
"sub_entity_id": null,
"root_entity_id": "00000daf-0000-4000-8000-000000000daf",
"replaced_model_id": null
@@ -6989,7 +6989,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "slime_elite",
"entry_id": "monster-slime_elite",
"sub_entity_id": null,
"root_entity_id": "00000db0-0000-4000-8000-000000000db0",
"replaced_model_id": null
@@ -7143,7 +7143,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "king_slime",
"entry_id": "monster-king_slime",
"sub_entity_id": null,
"root_entity_id": "00000db1-0000-4000-8000-000000000db1",
"replaced_model_id": null

View File

@@ -6379,7 +6379,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "kapa_drake",
"entry_id": "monster-kapa_drake",
"sub_entity_id": null,
"root_entity_id": "00001194-0000-4000-8000-000000001194",
"replaced_model_id": null
@@ -6531,7 +6531,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "junior_neki",
"entry_id": "monster-junior_neki",
"sub_entity_id": null,
"root_entity_id": "00001195-0000-4000-8000-000000001195",
"replaced_model_id": null
@@ -6683,7 +6683,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "junior_bugi",
"entry_id": "monster-junior_bugi",
"sub_entity_id": null,
"root_entity_id": "00001196-0000-4000-8000-000000001196",
"replaced_model_id": null
@@ -6835,7 +6835,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "stump",
"entry_id": "monster-stump",
"sub_entity_id": null,
"root_entity_id": "00001197-0000-4000-8000-000000001197",
"replaced_model_id": null
@@ -6988,7 +6988,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "dile",
"entry_id": "monster-dile",
"sub_entity_id": null,
"root_entity_id": "00001198-0000-4000-8000-000000001198",
"replaced_model_id": null
@@ -7142,7 +7142,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "mushmom",
"entry_id": "monster-mushmom",
"sub_entity_id": null,
"root_entity_id": "00001199-0000-4000-8000-000000001199",
"replaced_model_id": null

View File

@@ -6379,7 +6379,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "kapa_drake",
"entry_id": "monster-kapa_drake",
"sub_entity_id": null,
"root_entity_id": "0000157c-0000-4000-8000-00000000157c",
"replaced_model_id": null
@@ -6531,7 +6531,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "octopus",
"entry_id": "monster-octopus",
"sub_entity_id": null,
"root_entity_id": "0000157d-0000-4000-8000-00000000157d",
"replaced_model_id": null
@@ -6683,7 +6683,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "junior_bugi",
"entry_id": "monster-junior_bugi",
"sub_entity_id": null,
"root_entity_id": "0000157e-0000-4000-8000-00000000157e",
"replaced_model_id": null
@@ -6835,7 +6835,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "junior_neki",
"entry_id": "monster-junior_neki",
"sub_entity_id": null,
"root_entity_id": "0000157f-0000-4000-8000-00000000157f",
"replaced_model_id": null
@@ -6987,7 +6987,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "dile",
"entry_id": "monster-dile",
"sub_entity_id": null,
"root_entity_id": "00001580-0000-4000-8000-000000001580",
"replaced_model_id": null
@@ -7141,7 +7141,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "slime_elite",
"entry_id": "monster-slime_elite",
"sub_entity_id": null,
"root_entity_id": "00001581-0000-4000-8000-000000001581",
"replaced_model_id": null
@@ -7295,7 +7295,7 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "king_slime",
"entry_id": "monster-king_slime",
"sub_entity_id": null,
"root_entity_id": "00001582-0000-4000-8000-000000001582",
"replaced_model_id": null

View 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();

View 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 }), []);
});

View 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();

View 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);
});

View File

@@ -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) {

View File

@@ -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

View File

@@ -295,6 +295,7 @@ if dmg > 0 then
self:ApplyPoisonToMonster(m, poison)
end
end
self:ShowDmgPop(m.slot, dmg)
self:MonsterHitMotion(m.slot)
local killed = false
if m.hp <= 0 then

View File

@@ -181,6 +181,7 @@ end
self.DeckInspectKind = ""
self.ClassDeckMode = false
self.ClassDeckClass = ""
self.CodexMode = false
self.DebugCardPickerMode = false
self:RenderClassDeckTabs()
self.DeckAllOpen = true
@@ -211,6 +212,9 @@ local title = "모든 덱"
if self.ClassDeckMode == true then
pile = self.ClassDeckCards or {}
title = self.ClassDeckTitle
if self.CodexMode == true then
title = "카드 도감"
end
if self.DebugCardPickerMode == true then
title = title .. " - 테스트 카드 추가"
end

View File

@@ -550,7 +550,7 @@ if c.kind == "Attack" then
if targetIdx ~= nil and targetIdx > 0 then
local prev = self.TargetIndex
self.TargetIndex = targetIdx
local killed = self:DealDamageToTarget(total / hitN, c.pierce == true)
local killed = self:DealDamageToTarget(math.floor(total / hitN), c.pierce == true)
self.TargetIndex = prev
if killed == true then roundKilled = true end
end

View File

@@ -151,7 +151,6 @@ if p.effect == "heal" then
self.PlayerHp = math.min(self.PlayerHp + p.value, self.PlayerMaxHp)
elseif p.effect == "damage" then
self:DealDamageToTarget(p.value, false)
self:ShowDmgPop(self.TargetIndex, p.value)
elseif p.effect == "strength" then
self.PlayerStr = self.PlayerStr + p.value
elseif p.effect == "block" then

View File

@@ -77,9 +77,14 @@ for i = 1, 3 do
self:SetEntityEnabled(base, true)
self:SetText(base .. "/Name", o.name)
self:SetText(base .. "/Desc", o.desc)
local sc = self.Cards[o.starter]
local sc = nil
if o.starter ~= nil and o.starter ~= "" then
sc = self.Cards[o.starter]
end
if sc ~= nil then
self:SetText(base .. "/Starter", "대표 카드: " .. sc.name)
else
self:SetText(base .. "/Starter", "대표 카드: 계보 유지")
end
else
self:SetEntityEnabled(base, false)
@@ -111,6 +116,8 @@ if starter ~= "" then
if sc ~= nil then
self:Toast(tostring(tier) .. "차 전직: " .. self:JobLabel() .. "! 신규 카드 - " .. sc.name)
end
else
self:Toast(tostring(tier) .. "차 전직: " .. self:JobLabel() .. "!")
end
self:SetText("/ui/RunUIGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
self:SetEntityEnabled("/ui/SelectUIGroup/JobSelectHud", false)

View File

@@ -47,7 +47,7 @@ if slot ~= 0 and self.RewardChoices ~= nil then
table.insert(self.RunDeck, id)
end
end
if self.BonusRewardScreens ~= nil and self.BonusRewardScreens > 0 and slot ~= 0 then
if self.BonusRewardScreens ~= nil and self.BonusRewardScreens > 0 then
self.BonusRewardScreens = self.BonusRewardScreens - 1
self:OfferReward()
return

View File

@@ -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

View File

@@ -9,7 +9,17 @@ self.ShopBought = { false, false, false }
for i = 1, 3 do
self.ShopChoices[i] = pool[math.random(1, #pool)]
end
self.ShopRelic = self.RelicPool[math.random(1, #self.RelicPool)]
local relicChoices = {}
for i = 1, #self.RelicPool do
if self:HasRelic(self.RelicPool[i]) == false then
table.insert(relicChoices, self.RelicPool[i])
end
end
if #relicChoices > 0 then
self.ShopRelic = relicChoices[math.random(1, #relicChoices)]
else
self.ShopRelic = ""
end
self.ShopRelicBought = false
local pkeys = {}
for pid, _ in pairs(self.Potions) do
@@ -38,6 +48,7 @@ for i = 1, 3 do
end
local rr = self.Relics[self.ShopRelic]
if rr ~= nil then
self:SetEntityEnabled("/ui/RunUIGroup/ShopHud/Relic", true)
self:SetText("/ui/RunUIGroup/ShopHud/Relic/Label", rr.name .. " — " .. rr.desc)
self:SetText("/ui/RunUIGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 메소")
local re = _EntityService:GetEntityByPath("/ui/RunUIGroup/ShopHud/Relic")
@@ -48,6 +59,8 @@ if rr ~= nil then
re.SpriteGUIRendererComponent.Color = Color(0.7, 0.55, 0.85, 1)
end
end
else
self:SetEntityEnabled("/ui/RunUIGroup/ShopHud/Relic", false)
end
local pp = self.Potions[self.ShopPotion]
if pp ~= nil then
@@ -65,6 +78,9 @@ end`),
method('BuyRelic', `if self.ShopRelicBought == true then
return
end
if self.ShopRelic == nil or self.ShopRelic == "" then
return
end
if self.Gold < ${RELIC_PRICE} then
return
end

View File

@@ -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 };

View File

@@ -23,30 +23,39 @@ const JOBS = {
{ id: 'page', name: '페이지', desc: '속성 차지 특화\n썬더/블리자드 차지\n파워 가드', starter: 'ThunderCharge', tier: 2, parent: 'warrior' },
{ id: 'spearman', name: '스피어맨', desc: '방어·관통 특화\n피어스 · 아이언 월\n하이퍼 바디', starter: 'Pierce', tier: 2, parent: 'warrior' },
],
fighter: [
{ id: 'crusader', name: '크루세이더', desc: 'Fighter의 3차 전직\n콤보 압박과 화력 심화\n파이터 카드 계승', starter: '', tier: 3, parent: 'fighter' },
],
page: [
{ id: 'knight', name: '나이트', desc: 'Page의 3차 전직\n방어와 차지 운영 심화\n페이지 카드 계승', starter: '', tier: 3, parent: 'page' },
],
spearman: [
{ id: 'berserker', name: '버서커', desc: 'Spearman의 3차 전직\n관통과 생존 운영 심화\n스피어맨 카드 계승', starter: '', tier: 3, parent: 'spearman' },
],
magician: [
{ id: 'firepoison', name: '위자드(불·독)', desc: '화염·독 특화\n파이어 애로우\n포이즌 브레스 · 앰플', starter: 'FireArrow', tier: 2, parent: 'magician' },
{ id: 'icelightning', name: '위자드(썬·콜)', desc: '광역·빙결 특화\n썬더 볼트(전체)\n콜드 빔 · 칠링 스텝', starter: 'ThunderBolt', tier: 2, parent: 'magician' },
{ 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)) {
for (const j of jobs) {
if (!CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
if (j.starter && !CARDS.cards[j.starter]) throw new Error(`[gen-slaydeck] JOBS.${cls}.${j.id} 대표 카드 없음: ${j.starter}`);
}
}
const CLASS_GROUPS = {
warrior: ['warrior', 'fighter', 'page', 'spearman'],
warrior: ['warrior', 'fighter', 'crusader', 'page', 'knight', 'spearman', 'berserker'],
magician: ['magician', 'firepoison', 'icelightning', 'cleric'],
rogue: ['rogue', 'assassin', 'hermit', 'thief', 'thiefmaster'],
};
@@ -54,8 +63,11 @@ const CLASS_GROUPS = {
const CLASS_LINEAGES = {
warrior: ['warrior'],
fighter: ['warrior', 'fighter'],
crusader: ['warrior', 'fighter', 'crusader'],
page: ['warrior', 'page'],
knight: ['warrior', 'page', 'knight'],
spearman: ['warrior', 'spearman'],
berserker: ['warrior', 'spearman', 'berserker'],
magician: ['magician'],
firepoison: ['magician', 'firepoison'],
icelightning: ['magician', 'icelightning'],

View File

@@ -69,7 +69,7 @@ export function buildMonsterInstance({ enemyId, enemy, name, guid, mapTag, x, gr
jsonString: {
name, path, nameEditable: true, enable: true, visible: true, localize: false,
displayOrder: 4, pathConstraints: '///', revision: 2,
origin: { type: 'Model', entry_id: enemyId, sub_entity_id: null, root_entity_id: guid, replaced_model_id: null },
origin: { type: 'Model', entry_id: modelEntryId(enemyId), sub_entity_id: null, root_entity_id: guid, replaced_model_id: null },
modelId: modelEntryId(enemyId),
'@components': components,
'@version': 1,

View File

@@ -42,7 +42,7 @@ test('buildMonsterModel: 원본 skeleton 비변형(순수 함수)', () => {
test('buildMonsterInstance: 모델 연결·컴포넌트 값', () => {
const e = buildMonsterInstance({ enemyId: 'slime', enemy, name: 'slime', guid: '00000bb9-0000-4000-8000-000000000bb9', mapTag: '03', x: 3.4, group: 'elite' });
assert.equal(e.jsonString.modelId, 'monster-slime');
assert.equal(e.jsonString.origin.entry_id, 'slime');
assert.equal(e.jsonString.origin.entry_id, 'monster-slime');
assert.equal(e.jsonString.origin.root_entity_id, e.id);
assert.equal(e.path, '/maps/map03/slime');
const comp = (t) => e.jsonString['@components'].find((c) => c['@type'] === t);

View File

@@ -0,0 +1,82 @@
import { readFileSync } from 'node:fs';
const cards = JSON.parse(readFileSync('data/cards.json', 'utf8')).cards;
const rogueClasses = new Set(['rogue', 'thief', 'thiefmaster', 'assassin', 'hermit']);
const mapleSkillCards = {
DoubleStab: '더블 스탭',
LuckySeven: '럭키 세븐',
Haste: '헤이스트',
DarkSight: '다크 사이트',
FlashJump: '플래시 점프',
NimbleBody: '님블 바디',
SavageBlow: '새비지 블로우',
CriticalEdge: '크리티컬 엣지',
Steal: '스틸',
DaggerAcceleration: '대거 액셀레이션',
Karma: '카르마',
DaggerMastery: '대거 마스터리',
PhysicalTraining: '피지컬 트레이닝',
ShieldMastery: '실드 마스터리',
ThiefAgility: '시프 어질리티',
EdgeCarnival: '엣지 카니발',
MuspelHeim: '무스펠 하임',
MesoExplosion: '메소 익스플로젼',
DarkFlare: '다크 플레어',
PickPocket: '픽 파킷',
ShadowPartner: '쉐도우 파트너',
AdvancedDarkSight: '어드밴스드 다크 사이트',
IntoDarkness: '인투 다크니스',
Venom: '베놈',
Grid: '그리드',
RadicalDarkness: '래디컬 다크니스',
ShurikenBurst: '슈리켄 버스트',
WindTalisman: '윈드 탈리스만',
MarkOfAssassin: '마크 오브 어쌔신',
ShadowRush: '쉐도우 러쉬',
ShadowLeap: '쉐도우 리프',
ShadowBlink: '쉐도우 블링크',
JavelinMastery: '자벨린 마스터리',
JavelinAcceleration: '자벨린 액셀레이션',
CriticalThrow: '크리티컬 스로우',
AssassinPhysicalTraining: '피지컬 트레이닝',
TripleThrow: '트리플 스로우',
ShurikenChallenge: '슈리켄 챌린지',
HermitDarkFlare: '다크 플레어',
HermitShadowPartner: '쉐도우 파트너',
SpiritJavelin: '스피릿 자벨린',
HermitRadicalDarkness: '래디컬 다크니스',
HermitVenom: '베놈',
SkilledJavelin: '숙련된 표창술',
HermitAdrenaline: '아드레날린',
};
const errors = [];
for (const [id, expectedName] of Object.entries(mapleSkillCards)) {
if (!cards[id]) errors.push(`원본 스킬 카드 없음: ${id}`);
else if (cards[id].name !== expectedName) errors.push(`원본 스킬명 변경: ${id} (${cards[id].name} != ${expectedName})`);
}
const customCards = Object.entries(cards).filter(([id, card]) => rogueClasses.has(card.class) && !mapleSkillCards[id]);
if (customCards.length !== 78) errors.push(`도적 비스킬 카드 수 불일치: ${customCards.length} != 78`);
const names = new Map();
for (const [id, card] of Object.entries(cards)) {
if (!names.has(card.name)) names.set(card.name, []);
names.get(card.name).push(id);
}
const nonRogueNames = new Set(Object.values(cards).filter((card) => !rogueClasses.has(card.class) && card.class !== 'shiv').map((card) => card.name));
for (const [id, card] of customCards) {
const sameNameIds = names.get(card.name) || [];
if (sameNameIds.length > 1) errors.push(`비스킬 카드명 중복: ${id} ${card.name} (${sameNameIds.join(', ')})`);
if (nonRogueNames.has(card.name)) errors.push(`다른 직업 카드명 충돌: ${id} ${card.name}`);
}
console.log(`메이플 원본 스킬명 고정 ${Object.keys(mapleSkillCards).length}장 | 도적 비스킬 고유 이름 ${customCards.length}`);
if (errors.length > 0) {
for (const error of errors) console.error(`ERROR: ${error}`);
process.exitCode = 1;
} else {
console.log('RESULT: 도적 카드 이름 규칙 이상 0');
}