Files
DS-Combat_analy/분석도구/v2/calculate_dps_scenarios_v2.py

566 lines
20 KiB
Python
Raw Normal View History

2025-10-28 12:34:12 +09:00
#!/usr/bin/env python3
"""
스토커 DPS 시나리오 계산 v2
- 3 시나리오 계산: 평타, 로테이션 (30), 버스트 (10)
- 특수 상황 분석: DoT, 소환체, 패링
"""
import json
import math
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Tuple, Any
from config import (
get_output_dir, STALKERS, STALKER_INFO,
BASE_DAMAGE_FORMULA, DOT_SKILLS, DOT_DAMAGE,
SUMMON_SKILLS, UTILITY_SKILLS, ANALYSIS_BASELINE
)
def load_validated_data(output_dir: Path) -> Dict:
"""validated_data.json 또는 intermediate_data.json 로드"""
validated_file = output_dir / "validated_data.json"
intermediate_file = output_dir / "intermediate_data.json"
if validated_file.exists():
data_file = validated_file
print(f"Using validated_data.json")
elif intermediate_file.exists():
data_file = intermediate_file
print(f"Using intermediate_data.json")
else:
raise FileNotFoundError(f"No data file found in {output_dir}")
with open(data_file, 'r', encoding='utf-8') as f:
return json.load(f)
def calculate_base_damage(stalker_id: str, stats: Dict) -> float:
"""BaseDamage 계산 (Level 20, GearScore 400 기준)"""
role = STALKER_INFO[stalker_id]['role']
# 주 스탯 결정 (실제 높은 스탯 또는 역할 기준)
if stalker_id == 'hilda':
# Hilda: STR 20 → Physical STR
damage_type = 'physical_str'
elif stalker_id == 'urud':
# Urud: DEX 20 → Physical DEX
damage_type = 'physical_dex'
elif stalker_id == 'nave':
# Nave: INT 25 → Magical
damage_type = 'magical'
elif stalker_id == 'baran':
# Baran: STR 25 → Physical STR
damage_type = 'physical_str'
elif stalker_id == 'rio':
# Rio: DEX 25 → Physical DEX
damage_type = 'physical_dex'
elif stalker_id == 'clad':
# Clad: STR 15 (not WIS!) → Support
damage_type = 'support'
elif stalker_id == 'rene':
# Rene: INT 20 → Magical
damage_type = 'magical'
elif stalker_id == 'sinobu':
# Sinobu: DEX 25 → Physical DEX
damage_type = 'physical_dex'
elif stalker_id == 'lian':
# Lian: DEX 20 → Physical DEX
damage_type = 'physical_dex'
elif stalker_id == 'cazimord':
# Cazimord: DEX 25, STR 15 → Physical DEX
damage_type = 'physical_dex'
else:
# Default fallback
damage_type = 'physical_str'
return BASE_DAMAGE_FORMULA[damage_type](stats)
def calculate_basic_attack_dps(stalker_id: str, stalker_data: Dict, base_damage: float) -> Dict:
"""시나리오 1: 평타 DPS 계산"""
basic_attacks = stalker_data.get('basicAttacks', {})
# 첫 번째 무기 타입의 평타 사용 (대부분 한 가지 무기만 사용)
weapon_type = list(basic_attacks.keys())[0] if basic_attacks else None
if not weapon_type:
return {
'dps': 0,
'combo_time': 0,
'total_multiplier': 0,
'attacks': [],
'notes': '평타 데이터 없음'
}
attacks = basic_attacks[weapon_type]
# 총 콤보 시간 및 평타 배율 합계 계산
combo_time = sum(atk['actualDuration'] for atk in attacks)
# attackMultiplier는 AddNormalAttackPer 값 (음수는 감소, 양수는 증가)
# 실제 배율 = 1.0 + (attackMultiplier / 100)
total_multiplier = sum(1.0 + (atk['attackMultiplier'] / 100.0) for atk in attacks)
# 평타 DPS 계산
if combo_time > 0:
basic_dps = (base_damage * total_multiplier) / combo_time
else:
basic_dps = 0
return {
'dps': round(basic_dps, 2),
'combo_time': round(combo_time, 2),
'total_multiplier': round(total_multiplier, 2),
'base_damage': round(base_damage, 2),
'attacks': [
{
'index': atk['index'],
'name': atk['montageName'],
'duration': round(atk['actualDuration'], 2),
'multiplier': round(1.0 + (atk['attackMultiplier'] / 100.0), 2)
}
for atk in attacks
],
'notes': ''
}
def calculate_skill_rotation_dps(stalker_id: str, stalker_data: Dict, base_damage: float, duration: float = 30.0) -> Dict:
"""시나리오 2: 스킬 로테이션 DPS (30초 기본)"""
skills = stalker_data.get('skills', {})
stats = stalker_data['stats']
# 마나 회복 (0.2/초 + 룬 +70% = 0.34/초)
mana_regen_rate = stats.get('manaRegen', 0.2) * (1.0 + ANALYSIS_BASELINE['rune_effect']['cooltime_reduction'])
# 쿨타임 감소 (왜곡 룬 -25%)
cooltime_reduction = ANALYSIS_BASELINE['rune_effect']['cooltime_reduction']
# 공격 스킬만 필터링 (유틸리티 제외, 궁극기 제외)
attack_skills = []
for skill_id, skill in skills.items():
if skill_id in UTILITY_SKILLS:
continue
if skill.get('bIsUltimate', False):
continue
if not skill.get('montageData'):
continue
# 시퀀스 길이 계산 (Ready, Equipment 제외)
sequence_length = sum(
m['actualDuration']
for m in skill['montageData']
if 'Ready' not in m['assetName'] and 'Equipment' not in m['assetName']
)
if sequence_length > 0:
attack_skills.append({
'id': skill_id,
'name': skill['name'],
'damage_rate': skill['skillDamageRate'],
'cooltime': skill['coolTime'] * (1.0 - cooltime_reduction),
'casting_time': skill.get('castingTime', 0),
'sequence_length': sequence_length,
'mana_cost': skill['manaCost'],
'skill_type': skill.get('skillAttackType', 'Physical')
})
# 쿨타임 짧은 순서로 정렬
attack_skills.sort(key=lambda x: x['cooltime'])
# 로테이션 시뮬레이션
current_time = 0.0
current_mana = stats.get('mp', 50)
skill_usage = {s['id']: {'count': 0, 'damage': 0, 'next_available': 0} for s in attack_skills}
basic_attack_time = 0.0
# 평타 DPS (필러로 사용)
basic_result = calculate_basic_attack_dps(stalker_id, stalker_data, base_damage)
basic_dps = basic_result['dps']
# 30초 동안 스킬 사용
time_step = 0.1 # 0.1초 단위로 시뮬레이션
while current_time < duration:
# 현재 사용 가능한 스킬 찾기
skill_used = False
for skill in attack_skills:
# 쿨타임 확인
if skill_usage[skill['id']]['next_available'] > current_time:
continue
# 마나 확인
if current_mana < skill['mana_cost']:
continue
# 스킬 사용
skill_time = skill['casting_time'] + skill['sequence_length']
if current_time + skill_time > duration:
break # 시간 초과
# 피해 계산
if 'Magical' in skill['skill_type']:
skill_damage = base_damage * skill['damage_rate']
else:
skill_damage = base_damage * skill['damage_rate']
skill_usage[skill['id']]['count'] += 1
skill_usage[skill['id']]['damage'] += skill_damage
skill_usage[skill['id']]['next_available'] = current_time + skill_time + skill['cooltime']
current_time += skill_time
current_mana -= skill['mana_cost']
skill_used = True
break
if not skill_used:
# 스킬 사용 불가 시 평타 사용
basic_attack_time += time_step
current_time += time_step
# 마나 회복
current_mana = min(current_mana + mana_regen_rate * time_step, stats.get('mp', 50))
# 총 피해 계산
total_skill_damage = sum(usage['damage'] for usage in skill_usage.values())
basic_damage = basic_dps * basic_attack_time
total_damage = total_skill_damage + basic_damage
rotation_dps = total_damage / duration
return {
'dps': round(rotation_dps, 2),
'duration': duration,
'base_damage': round(base_damage, 2),
'skill_damage': round(total_skill_damage, 2),
'basic_damage': round(basic_damage, 2),
'basic_attack_time': round(basic_attack_time, 2),
'skill_usage': {
skill_id: {
'name': next((s['name'] for s in attack_skills if s['id'] == skill_id), ''),
'count': usage['count'],
'damage': round(usage['damage'], 2)
}
for skill_id, usage in skill_usage.items() if usage['count'] > 0
},
'notes': ''
}
def calculate_burst_dps(stalker_id: str, stalker_data: Dict, base_damage: float, duration: float = 10.0) -> Dict:
"""시나리오 3: 버스트 DPS (10초)"""
skills = stalker_data.get('skills', {})
stats = stalker_data['stats']
# 궁극기 찾기 (유틸리티 제외)
ultimate_skill = None
ultimate_id = None
for skill_id, skill in skills.items():
if skill.get('bIsUltimate', False) and skill_id not in UTILITY_SKILLS:
ultimate_skill = skill
ultimate_id = skill_id
break
# 모든 공격 스킬 (유틸리티 제외)
attack_skills = []
for skill_id, skill in skills.items():
if skill_id in UTILITY_SKILLS:
continue
if skill.get('bIsUltimate', False):
continue
if not skill.get('montageData'):
continue
# 시퀀스 길이 계산
sequence_length = sum(
m['actualDuration']
for m in skill['montageData']
if 'Ready' not in m['assetName'] and 'Equipment' not in m['assetName']
)
if sequence_length > 0:
attack_skills.append({
'id': skill_id,
'name': skill['name'],
'damage_rate': skill['skillDamageRate'],
'casting_time': skill.get('castingTime', 0),
'sequence_length': sequence_length,
'mana_cost': skill['manaCost']
})
# 버스트 시나리오: 궁극기 → 모든 스킬 → 평타
current_time = 0.0
total_damage = 0.0
skill_order = []
# 1. 궁극기 사용 (있는 경우)
if ultimate_skill:
ult_sequence = sum(
m['actualDuration']
for m in ultimate_skill.get('montageData', [])
if 'Ready' not in m['assetName'] and 'Equipment' not in m['assetName']
)
ult_time = ultimate_skill.get('castingTime', 0) + ult_sequence
if current_time + ult_time <= duration:
ult_damage = base_damage * ultimate_skill['skillDamageRate']
total_damage += ult_damage
current_time += ult_time
skill_order.append({
'time': round(current_time, 2),
'skill': ultimate_skill['name'],
'damage': round(ult_damage, 2),
'type': 'ultimate'
})
# 2. 모든 스킬 한 번씩 사용 (피해량 높은 순서)
attack_skills.sort(key=lambda x: x['damage_rate'], reverse=True)
for skill in attack_skills:
skill_time = skill['casting_time'] + skill['sequence_length']
if current_time + skill_time > duration:
continue
skill_damage = base_damage * skill['damage_rate']
total_damage += skill_damage
current_time += skill_time
skill_order.append({
'time': round(current_time, 2),
'skill': skill['name'],
'damage': round(skill_damage, 2),
'type': 'skill'
})
# 3. 남은 시간 평타
remaining_time = duration - current_time
basic_result = calculate_basic_attack_dps(stalker_id, stalker_data, base_damage)
basic_dps = basic_result['dps']
basic_damage = basic_dps * remaining_time
total_damage += basic_damage
burst_dps = total_damage / duration
return {
'dps': round(burst_dps, 2),
'duration': duration,
'base_damage': round(base_damage, 2),
'ultimate_damage': round(skill_order[0]['damage'], 2) if skill_order and skill_order[0]['type'] == 'ultimate' else 0,
'skill_damage': round(sum(s['damage'] for s in skill_order if s['type'] == 'skill'), 2),
'basic_damage': round(basic_damage, 2),
'remaining_time': round(remaining_time, 2),
'skill_order': skill_order,
'has_ultimate': ultimate_skill is not None,
'notes': ''
}
def calculate_dot_dps_by_hp(target_hp_list: List[int] = [100, 500, 1000]) -> Dict:
"""DoT 스킬 DPS (대상 HP별)"""
dot_results = {}
for skill_id, skill_info in DOT_SKILLS.items():
dot_type = skill_info['dot_type']
dot_config = DOT_DAMAGE[dot_type]
dot_results[skill_id] = {
'stalker': skill_info['stalker'],
'name': skill_info['name'],
'dot_type': dot_type,
'description': dot_config['description'],
'dps_by_hp': {}
}
for target_hp in target_hp_list:
if 'rate' in dot_config:
# 퍼센트 피해 (Poison, Burn)
dot_damage = target_hp * dot_config['rate']
dot_dps = dot_damage / dot_config['duration']
else:
# 고정 피해 (Bleed)
dot_damage = dot_config['damage']
dot_dps = dot_damage / dot_config['duration']
dot_results[skill_id]['dps_by_hp'][target_hp] = round(dot_dps, 2)
return dot_results
def calculate_summon_independent_dps(stalker_data: Dict, base_damage: float) -> Dict:
"""소환체 독립 DPS 계산"""
summon_results = {}
for skill_id, summon_info in SUMMON_SKILLS.items():
stalker_id = summon_info['stalker']
# 해당 스토커의 스킬인지 확인
skills = stalker_data.get('skills', {})
if skill_id not in skills:
continue
skill = skills[skill_id]
active_duration = skill.get('activeDuration', 0)
if summon_info['summon'] == 'Ifrit':
# Ifrit: 3개 몽타주 순차 루프 (2.87 + 2.90 + 2.52 = 8.29초 사이클)
# 20초 지속
montage_data = skill.get('montageData', [])
cycle_time = sum(m['actualDuration'] for m in montage_data if 'Ready' not in m['assetName'])
attack_count = (active_duration / cycle_time) * len(montage_data)
# Ifrit 공격: BaseDamage × 1.2
total_damage = base_damage * 1.2 * attack_count
summon_dps = total_damage / active_duration if active_duration > 0 else 0
summon_results[skill_id] = {
'name': summon_info['name'],
'summon': summon_info['summon'],
'active_duration': active_duration,
'cycle_time': round(cycle_time, 2),
'attack_count': round(attack_count, 2),
'dps': round(summon_dps, 2),
'notes': f'{len(montage_data)}개 몽타주 순차 루프'
}
elif summon_info['summon'] == 'Shiva':
# Shiva: 단일 몽타주 2.32초 반복
# 60초 지속
montage_name = summon_info.get('montage', '')
# TODO: 실제 몽타주 시간 찾아서 계산
cycle_time = 2.32 # 임시값
attack_count = active_duration / cycle_time
# Shiva 공격: BaseDamage × 0.8
total_damage = base_damage * 0.8 * attack_count
summon_dps = total_damage / active_duration if active_duration > 0 else 0
summon_results[skill_id] = {
'name': summon_info['name'],
'summon': summon_info['summon'],
'active_duration': active_duration,
'cycle_time': round(cycle_time, 2),
'attack_count': round(attack_count, 2),
'dps': round(summon_dps, 2),
'notes': '단일 몽타주 반복'
}
return summon_results
def save_dps_results_json(all_results: Dict, output_dir: Path) -> None:
"""DPS 계산 결과를 JSON으로 저장 (Claude 분석용)"""
# 정렬된 데이터 준비
scenario1_sorted = sorted(
[(sid, all_results[sid]['scenario1']) for sid in STALKERS if sid in all_results],
key=lambda x: x[1]['dps'],
reverse=True
)
scenario2_sorted = sorted(
[(sid, all_results[sid]['scenario2']) for sid in STALKERS if sid in all_results],
key=lambda x: x[1]['dps'],
reverse=True
)
scenario3_sorted = sorted(
[(sid, all_results[sid]['scenario3']) for sid in STALKERS if sid in all_results],
key=lambda x: x[1]['dps'],
reverse=True
)
# 정렬된 순위 정보 추가
for rank, (stalker_id, _) in enumerate(scenario1_sorted, 1):
all_results[stalker_id]['scenario1']['rank'] = rank
for rank, (stalker_id, _) in enumerate(scenario2_sorted, 1):
all_results[stalker_id]['scenario2']['rank'] = rank
for rank, (stalker_id, _) in enumerate(scenario3_sorted, 1):
all_results[stalker_id]['scenario3']['rank'] = rank
# JSON 저장
output_file = output_dir / "dps_raw_results.json"
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(all_results, f, ensure_ascii=False, indent=2)
print(f"Generated: {output_file}")
def main():
"""메인 실행 함수"""
print("=" * 80)
print("DPS 시나리오 계산 v2")
print("=" * 80)
# 1. 출력 디렉토리 가져오기 (가장 최근 v2 폴더)
output_dir = get_output_dir(create_new=False)
print(f"\nOutput directory: {output_dir}")
# 2. validated_data.json 로드
print("\nLoading validated_data.json...")
validated_data = load_validated_data(output_dir)
print(f"Loaded data for {len(validated_data)} stalkers")
# 3. 각 스토커별 DPS 계산
print("\nCalculating DPS scenarios...")
all_results = {}
for stalker_id in STALKERS:
print(f"\n Processing: {STALKER_INFO[stalker_id]['name']} ({stalker_id})...")
stalker_data = validated_data.get(stalker_id, {})
if not stalker_data:
print(f" WARNING: No data for {stalker_id}, skipping")
continue
stats = stalker_data['stats']['stats']
# BaseDamage 계산
base_damage = calculate_base_damage(stalker_id, stats)
print(f" BaseDamage: {round(base_damage, 2)}")
# 시나리오 1: 평타 DPS
scenario1 = calculate_basic_attack_dps(stalker_id, stalker_data, base_damage)
print(f" Basic Attack DPS: {scenario1['dps']}")
# 시나리오 2: 스킬 로테이션 DPS (30초)
scenario2 = calculate_skill_rotation_dps(stalker_id, stalker_data, base_damage, 30.0)
print(f" Rotation DPS: {scenario2['dps']}")
# 시나리오 3: 버스트 DPS (10초)
scenario3 = calculate_burst_dps(stalker_id, stalker_data, base_damage, 10.0)
print(f" Burst DPS: {scenario3['dps']}")
all_results[stalker_id] = {
'scenario1': scenario1,
'scenario2': scenario2,
'scenario3': scenario3
}
# 소환체 분석 (Rene만)
if stalker_id == 'rene':
summon_analysis = calculate_summon_independent_dps(stalker_data, base_damage)
all_results[stalker_id]['summon_analysis'] = summon_analysis
print(f" Summon analysis complete: {len(summon_analysis)} skills")
# 4. DoT 분석 (전역)
print("\n Calculating DoT DPS...")
dot_analysis = calculate_dot_dps_by_hp([100, 500, 1000])
all_results['dot_analysis'] = dot_analysis
print(f" DoT analysis complete: {len(dot_analysis)} skills")
# 5. JSON 저장 (Claude 분석용)
print("\nSaving DPS results to JSON...")
save_dps_results_json(all_results, output_dir)
print("\n" + "=" * 80)
print("DPS calculation complete!")
print("=" * 80)
print("\nNext step: Run Claude analysis to generate 02_DPS_시나리오_비교분석_v2.md")
if __name__ == "__main__":
main()