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