Files
DS-Combat_analy/legacy/분석도구/v2/calculate_dps_scenarios_v2.py
2025-11-05 11:09:16 +09:00

566 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()