#!/usr/bin/env python3 """ 스토커 데이터 통합 추출 스크립트 v2 모든 JSON 소스에서 데이터를 추출하여 중간 데이터 파일 생성 - DT_Skill: 스킬 상세 정보 - DT_CharacterStat/Ability: 스토커 기본 정보 - Blueprint: 스킬 변수 (ActivationOrderGroup 등) - AnimMontage: 평타/스킬 타이밍, 공격 노티파이 """ import json import sys import re from pathlib import Path from typing import Dict, List, Any, Optional # config 임포트 sys.path.append(str(Path(__file__).parent)) import config def format_description(desc: str, desc_values: List) -> str: """ desc 문자열의 {0}, {1}, {2} 등을 descValues 배열 값으로 치환 Args: desc: 원본 설명 문자열 (예: "방패를 들어 {0}초 동안 반격 자세를 취합니다. 반격 성공 시 {1}%만큼 물리 피해를 줍니다.") desc_values: 값 배열 (예: [5, 80]) Returns: 치환된 설명 문자열 (예: "방패를 들어 5초 동안 반격 자세를 취합니다. 반격 성공 시 80%만큼 물리 피해를 줍니다.") """ if not desc or not desc_values: return desc # {0}, {1}, {2} 등을 descValues로 치환 result = desc for i, value in enumerate(desc_values): placeholder = f"{{{i}}}" result = result.replace(placeholder, str(value)) # 줄바꿈 제거 (마크다운 호환성) result = result.replace('\n', ' ').replace('\r', ' ') return result def load_json(file_path: Path) -> Dict: """JSON 파일 로드""" print(f"Loading: {file_path.name}") with open(file_path, 'r', encoding='utf-8') as f: data = json.load(f) return data def find_table(datatables: List[Dict], table_name: str) -> Optional[Dict]: """DataTable.json에서 특정 테이블 찾기""" for dt in datatables: if dt.get('AssetName') == table_name: return dt return None def find_asset_by_name(assets: List[Dict], name_pattern: str) -> List[Dict]: """에셋 이름 패턴으로 검색""" return [a for a in assets if name_pattern in a.get('AssetName', '')] def extract_character_stats(datatables: List[Dict]) -> Dict[str, Dict]: """ DT_CharacterStat에서 스토커 기본 정보 추출 Returns: {stalker_id: {name, job, stats, skills, ...}} """ print("\n=== DT_CharacterStat 추출 ===") char_stat_table = find_table(datatables, 'DT_CharacterStat') if not char_stat_table: print("[WARN] DT_CharacterStat 테이블을 찾을 수 없습니다.") return {} stalker_data = {} for row in char_stat_table.get('Rows', []): row_name = row['RowName'] if row_name not in config.STALKERS: continue data = row['Data'] # 스토커 이름 포맷: "English (Korean)" korean_name = data.get('name', '') info = config.STALKER_INFO.get(row_name, {}) english_name = info.get('english', row_name.capitalize()) formatted_name = f"{english_name} ({korean_name})" if korean_name else english_name stalker_data[row_name] = { 'id': row_name, 'name': formatted_name, # 영문(한글) 형식 'koreanName': korean_name, # 순수 한글 이름 'englishName': english_name, # 순수 영문 이름 'jobName': data.get('jobName', ''), 'stats': { 'str': data.get('str', 0), 'dex': data.get('dex', 0), 'int': data.get('int', 0), 'con': data.get('con', 0), 'wis': data.get('wis', 0) }, 'hp': data.get('hP', 0), 'mp': data.get('mP', 0), 'manaRegen': round(data.get('manaRegen', 0), 2), # 소수점 2자리 'physicalDamage': data.get('physicalDamage', 0), 'magicalDamage': data.get('magicalDamage', 0), 'criticalPer': data.get('criticalPer', 5), # 크리티컬 확률 'criticalDamage': data.get('criticalDamage', 0), # 크리티컬 추가 피해 'defaultSkills': data.get('defaultSkills', []), 'subSkill': data.get('subSkill', ''), 'ultimateSkill': data.get('ultimateSkill', ''), 'equipableTypes': data.get('equipableTypes', []), 'ultimatePoint': data.get('ultimatePoint', 0), 'source': 'DT_CharacterStat' } print(f" [OK] {stalker_data[row_name]['name']} ({row_name})") return stalker_data def extract_character_abilities(datatables: List[Dict]) -> Dict[str, Dict]: """ DT_CharacterAbility에서 평타 몽타주 정보 추출 Returns: {stalker_id: {attackMontageMap, abilities}} """ print("\n=== DT_CharacterAbility 추출 ===") char_ability_table = find_table(datatables, 'DT_CharacterAbility') if not char_ability_table: print("⚠️ DT_CharacterAbility 테이블을 찾을 수 없습니다.") return {} stalker_abilities = {} for row in char_ability_table.get('Rows', []): row_name = row['RowName'] if row_name not in config.STALKERS: continue data = row['Data'] stalker_abilities[row_name] = { 'attackMontageMap': data.get('attackMontageMap', {}), 'abilities': data.get('abilities', []), 'source': 'DT_CharacterAbility' } # 평타 콤보 수 계산 combo_counts = {} for weapon_type, montage_data in stalker_abilities[row_name]['attackMontageMap'].items(): montage_array = montage_data.get('montageArray', []) combo_counts[weapon_type] = len(montage_array) print(f" [OK] {row_name}: {combo_counts}") return stalker_abilities def extract_skills(datatables: List[Dict]) -> Dict[str, Dict]: """ DT_Skill에서 모든 스킬 정보 추출 Returns: {skill_id: {skill data}} """ print("\n=== DT_Skill 추출 ===") skill_table = find_table(datatables, 'DT_Skill') if not skill_table: print("⚠️ DT_Skill 테이블을 찾을 수 없습니다.") return {} all_skills = {} for row in skill_table.get('Rows', []): skill_id = row['RowName'] data = row['Data'] # 스토커 스킬만 추출 stalker_name = data.get('stalkerName', '') if stalker_name not in config.STALKERS: continue # 설명 처리: desc에서 {0}, {1} 등을 descValues로 치환 desc_raw = data.get('desc', '') desc_values_raw = data.get('descValues', []) # descValues의 float 값을 소수점 둘째자리로 반올림 desc_values = [] for val in desc_values_raw: if isinstance(val, float): desc_values.append(round(val, 2)) else: desc_values.append(val) desc_formatted = format_description(desc_raw, desc_values) all_skills[skill_id] = { 'skillId': skill_id, 'stalkerName': stalker_name, 'name': data.get('name', ''), 'desc': desc_raw, # 원본 desc (변수 포함) 'descFormatted': desc_formatted, # 변수 치환된 desc 'descValues': desc_values, # descValues 배열 'simpleDesc': data.get('simpleDesc', ''), 'bIsUltimate': data.get('bIsUltimate', False), 'bIsStackable': data.get('bIsStackable', False), 'maxStackCount': data.get('maxStackCount', 0), 'skillDamageRate': data.get('skillDamageRate', 0), 'skillAttackType': data.get('skillAttackType', ''), 'skillElementType': data.get('skillElementType', ''), 'manaCost': data.get('manaCost', 0), 'coolTime': data.get('coolTime', 0), 'castingTime': data.get('castingTime', 0), 'activeDuration': data.get('activeDuration', 0), # 소환수 지속시간 'activeRange': data.get('activeRange', {}), # tick, count, dist 등 'useMontages': data.get('useMontages', []), 'gameplayEffectSet': data.get('gameplayEffectSet', []), 'abilityClass': data.get('abilityClass', ''), 'icon': data.get('icon', ''), 'bUsable': data.get('bUsable', False), 'bUnSelectable': data.get('bUnSelectable', False), 'source': 'DT_Skill' } print(f" [OK] 총 {len(all_skills)}개 스킬 추출") # 스토커별 카운트 stalker_counts = {} for skill in all_skills.values(): stalker = skill['stalkerName'] stalker_counts[stalker] = stalker_counts.get(stalker, 0) + 1 for stalker_id in config.STALKERS: count = stalker_counts.get(stalker_id, 0) print(f" - {stalker_id}: {count}개") return all_skills def extract_skill_blueprints(blueprints: List[Dict]) -> Dict[str, Dict]: """ Blueprint.json에서 GA_Skill_ 블루프린트의 변수 추출 Returns: {blueprint_name: {variables}} """ print("\n=== GA_Skill Blueprint 추출 ===") skill_blueprints = {} ga_skills = [bp for bp in blueprints if 'GA_Skill' in bp.get('AssetName', '')] for bp in ga_skills: asset_name = bp['AssetName'] variables = {} for var in bp.get('Variables', []): var_name = var.get('Name', '') variables[var_name] = { 'name': var_name, 'type': var.get('Type', var.get('Category', 'unknown')), 'defaultValue': var.get('DefaultValue', 'N/A'), 'source': var.get('Source', 'Blueprint'), 'category': var.get('CategoryName', ''), 'isEditable': var.get('IsEditable', False), 'isBlueprintVisible': var.get('IsBlueprintVisible', False) } skill_blueprints[asset_name] = { 'assetName': asset_name, 'assetPath': bp.get('AssetPath', ''), 'parentClass': bp.get('ParentClass', ''), 'variables': variables, 'source': 'Blueprint' } print(f" [OK] 총 {len(skill_blueprints)}개 GA_Skill Blueprint 추출") return skill_blueprints def extract_anim_montages(montages: List[Dict]) -> Dict[str, Dict]: """ AnimMontage.json에서 몽타주 타이밍 및 노티파이 추출 - AddNormalAttackPer 추출 (ANS_AttackState_C 노티파이) - attackStateEndTime 추출 (ANS_AttackState_C 종료 시점) Returns: {montage_name: {timing, notifies, attackMultiplier, attackStateEndTime}} """ print("\n=== AnimMontage 추출 ===") all_montages = {} pc_montages = [m for m in montages if 'AM_PC_' in m.get('AssetName', '') or 'AM_Sum_' in m.get('AssetName', '')] for montage in pc_montages: asset_name = montage['AssetName'] # 공격 노티파이 추출 attack_notifies = [] attack_multiplier = 0.0 # AddNormalAttackPer (기본값 0) attack_state_end_time = None # ANS_AttackState 종료 시점 (평타용) for notify in montage.get('AnimNotifies', []): notify_class = notify.get('NotifyClass', '') notify_state_class = notify.get('NotifyStateClass', '') notify_name = notify.get('NotifyName', '') custom_props = notify.get('CustomProperties', {}) # ANS_AttackState_C에서 AddNormalAttackPer 및 종료 시점 추출 if 'ANS_AttackState' in notify_state_class: add_normal_attack_str = custom_props.get('AddNormalAttackPer', '0') try: attack_multiplier = float(add_normal_attack_str) except (ValueError, TypeError): attack_multiplier = 0.0 # ANS_AttackState 종료 시점 = TriggerTime + Duration trigger_time = notify.get('TriggerTime', 0) duration = notify.get('Duration', 0) attack_state_end_time = trigger_time + duration # 공격 판정 로직 (우선순위) is_attack_notify = False # 1. NotifyName에 키워드 포함 (부분 매칭) if any(keyword in notify_name for keyword in config.ATTACK_NOTIFY_KEYWORDS): is_attack_notify = True # 2. CustomProperties의 NotifyName 확인 - 1순위 실패 시 if not is_attack_notify: custom_notify_name = custom_props.get('NotifyName', '') if custom_notify_name and any(keyword in custom_notify_name for keyword in config.ATTACK_NOTIFY_KEYWORDS): is_attack_notify = True # 3. NotifyClass에 키워드 포함 (부분 매칭) - 1, 2순위 실패 시 if not is_attack_notify: if any(keyword in notify_class for keyword in config.ATTACK_NOTIFY_KEYWORDS): is_attack_notify = True # 4. SimpleSendEvent의 Event Tag 확인 (1, 2, 3순위 실패 시) if not is_attack_notify and 'SimpleSendEvent' in notify_class: event_tag = custom_props.get('Event Tag', '') if any(attack_tag in event_tag for attack_tag in config.ATTACK_EVENT_TAGS): is_attack_notify = True if is_attack_notify: attack_notifies.append({ 'notifyName': notify_name, 'notifyClass': notify_class, 'notifyStateClass': notify_state_class, 'triggerTime': notify.get('TriggerTime', 0), 'duration': notify.get('Duration', 0), 'notifyType': notify.get('NotifyType', ''), 'customProperties': custom_props }) # 시퀀스 길이 = SequenceLength / RateScale (actualDuration) seq_len = montage.get('SequenceLength', 0) rate_scale = montage.get('RateScale', 1.0) actual_duration = seq_len / rate_scale if rate_scale > 0 else seq_len all_montages[asset_name] = { 'assetName': asset_name, 'assetPath': montage.get('AssetPath', ''), 'sequenceLength': seq_len, 'rateScale': rate_scale, 'actualDuration': actual_duration, # 시퀀스 길이 (SequenceLength / RateScale) 'attackStateEndTime': attack_state_end_time, # ANS_AttackState 종료 시점 (평타용) 'attackMultiplier': attack_multiplier, # AddNormalAttackPer 'sections': montage.get('Sections', []), 'numSections': montage.get('NumSections', 0), 'allNotifies': montage.get('AnimNotifies', []), 'attackNotifies': attack_notifies, 'hasAttack': len(attack_notifies) > 0, 'blendInTime': montage.get('BlendInTime', 0), 'blendOutTime': montage.get('BlendOutTime', 0), 'source': 'AnimMontage' } print(f" [OK] 총 {len(all_montages)}개 몽타주 추출 (PC + Summon)") # 소환수 몽타주 확인 summon_montages = [m for m in all_montages.keys() if 'Summon' in m or 'Sum_' in m] if summon_montages: print(f" [INFO] 소환수 관련 몽타주: {len(summon_montages)}개") for sm in summon_montages: seq_len = all_montages[sm]['sequenceLength'] actual_dur = all_montages[sm]['actualDuration'] has_attack = all_montages[sm]['hasAttack'] print(f" - {sm}: {seq_len:.2f}초 (실제: {actual_dur:.2f}초), 공격={has_attack}") return all_montages def extract_npc_abilities(datatables: List[Dict]) -> Dict[str, Dict]: """ DT_NPCAbility에서 소환수(Ifrit, Shiva) 정보 추출 Returns: {npc_name: {attackMontageMap}} """ print("\n=== DT_NPCAbility 추출 ===") npc_ability_table = find_table(datatables, 'DT_NPCAbility') if not npc_ability_table: print("[WARN] DT_NPCAbility 테이블을 찾을 수 없습니다.") return {} npc_abilities = {} summon_names = ['ifrit', 'shiva'] # Rene의 소환수 for row in npc_ability_table.get('Rows', []): row_name = row['RowName'].lower() if row_name not in summon_names: continue data = row['Data'] attack_map = data.get('attackMontageMap', {}) npc_abilities[row_name] = { 'npcName': row['RowName'], 'attackMontageMap': attack_map, 'source': 'DT_NPCAbility' } # 몽타주 개수 출력 for weapon_type, montage_data in attack_map.items(): montage_array = montage_data.get('montageArray', []) print(f" [OK] {row['RowName']} ({weapon_type}): {len(montage_array)}개 몽타주") for i, montage_path in enumerate(montage_array): montage_name = montage_path.split('/')[-1].replace("'", "").split('.')[0] print(f" {i+1}. {montage_name}") return npc_abilities def extract_runes(datatables: List[Dict]) -> Dict[str, Dict]: """ DT_Rune에서 룬 데이터 추출 Returns: {runeId: {runeSet, level, name, desc, attributeModifies, ...}} """ print("\n=== DT_Rune 추출 ===") rune_table = find_table(datatables, 'DT_Rune') if not rune_table: print("[WARN] DT_Rune 테이블을 찾을 수 없습니다.") return {} runes = {} for row in rune_table.get('Rows', []): rune_id = row['RowName'] data = row['Data'] # attributeModifies 파싱 attr_modifies = [] for mod in data.get('attributeModifies', []): attr = mod.get('attribute', {}) attr_modifies.append({ 'attributeName': attr.get('attributeName', ''), 'value': mod.get('value', 0) }) runes[rune_id] = { 'runeId': rune_id, 'runeSet': data.get('runeSet', ''), 'level': data.get('level', 1), 'name': data.get('runeName', ''), 'desc': format_description(data.get('desc', ''), data.get('descValue', [])), 'descValue': data.get('descValue', []), 'attributeModifies': attr_modifies, 'unlockGold': data.get('unlockGold', 0), 'unlockSkillPoint': data.get('unlockSkillPoint', 0) } print(f" [OK] {len(runes)}개 룬 데이터 추출 완료") return runes def extract_rune_groups(datatables: List[Dict]) -> Dict[str, Dict]: """ DT_RuneGroup에서 룬 그룹 데이터 추출 Returns: {groupId: {name, type, coreLine, sub1Line, sub2Line}} """ print("\n=== DT_RuneGroup 추출 ===") rune_group_table = find_table(datatables, 'DT_RuneGroup') if not rune_group_table: print("[WARN] DT_RuneGroup 테이블을 찾을 수 없습니다.") return {} groups = {} for row in rune_group_table.get('Rows', []): group_id = row['RowName'] data = row['Data'] groups[group_id] = { 'groupId': group_id, 'name': data.get('name', ''), 'type': data.get('type', ''), 'coreLine': data.get('coreLine', []), 'sub1Line': data.get('sub1Line', []), 'sub2Line': data.get('sub2Line', []) } print(f" [OK] {data.get('name', group_id)}: Core({len(data.get('coreLine', []))}), Sub1({len(data.get('sub1Line', []))}), Sub2({len(data.get('sub2Line', []))})") return groups def extract_equipment(datatables: List[Dict]) -> Dict[str, Dict]: """ DT_Equip에서 장비 데이터 추출 Returns: {equipId: {name, equipSlotType, equipType, rarity, stats, ...}} """ print("\n=== DT_Equip 추출 ===") equip_table = find_table(datatables, 'DT_Equip') if not equip_table: print("[WARN] DT_Equip 테이블을 찾을 수 없습니다.") return {} equipment = {} for row in equip_table.get('Rows', []): equip_id = row['RowName'] data = row['Data'] # stats 파싱 stats = [] for stat in data.get('stats', []): attr = stat.get('attribute', {}) stats.append({ 'attributeName': attr.get('attributeName', ''), 'value': stat.get('value', 0), 'visible': stat.get('visible', False) }) equipment[equip_id] = { 'equipId': equip_id, 'name': data.get('name', ''), 'desc': data.get('desc', ''), 'equipSlotType': data.get('equipSlotType', ''), 'equipType': data.get('equipType', ''), 'rarity': data.get('rarity', ''), 'price': data.get('price', 0), 'sellPrice': data.get('sellPrice', 0), 'stats': stats, 'armor': data.get('armor', 0) } print(f" [OK] {len(equipment)}개 장비 데이터 추출 완료") return equipment def extract_float_constants(datatables: List[Dict]) -> Dict[str, float]: """ DT_Float에서 기어스코어 공식 상수 추출 Returns: {constantName: value} """ print("\n=== DT_Float (기어스코어 상수) 추출 ===") float_table = find_table(datatables, 'DT_Float') if not float_table: print("[WARN] DT_Float 테이블을 찾을 수 없습니다.") return {} constants = {} gearscore_keys = [ 'GearScoreEquipCommon', 'GearScoreEquipUncommon', 'GearScoreEquipRare', 'GearScoreEquipLegendary', 'GearScoreSkillPassive', 'GearScoreSkillPerk' ] for row in float_table.get('Rows', []): row_name = row['RowName'] if row_name in gearscore_keys: constants[row_name] = row['Data'].get('value', 0) print(f" [OK] {row_name}: {constants[row_name]}") return constants def organize_stalker_data( stalker_stats: Dict, stalker_abilities: Dict, all_skills: Dict, skill_blueprints: Dict, anim_montages: Dict, npc_abilities: Dict, runes: Dict, rune_groups: Dict, equipment: Dict, float_constants: Dict ) -> Dict[str, Dict]: """ 스토커별로 모든 데이터를 통합 정리 Returns: {stalker_id: {모든 데이터}} """ print("\n=== 스토커별 데이터 통합 ===") organized = {} for stalker_id in config.STALKERS: if stalker_id not in stalker_stats: print(f" [WARN] {stalker_id}: 기본 스탯 없음") continue stats = stalker_stats[stalker_id] abilities = stalker_abilities.get(stalker_id, {}) # 스토커의 스킬 목록 skill_ids = stats['defaultSkills'] + [stats['subSkill'], stats['ultimateSkill']] skill_ids = [sid for sid in skill_ids if sid] # 빈 문자열 제거 # 스킬 상세 정보 skills = {} for skill_id in skill_ids: if skill_id not in all_skills: print(f" [WARN] {stalker_id}: 스킬 {skill_id} 정보 없음") continue skill_data = all_skills[skill_id].copy() # Blueprint 정보 매칭 ability_class = skill_data.get('abilityClass', '') if ability_class: # '/Game/Blueprints/Abilities/GA_Skill_XXX.GA_Skill_XXX_C' -> 'GA_Skill_XXX' bp_name = ability_class.split('/')[-1].split('.')[0] if bp_name in skill_blueprints: skill_data['blueprintVariables'] = skill_blueprints[bp_name]['variables'] else: skill_data['blueprintVariables'] = {} # AnimMontage 정보 매칭 use_montages = skill_data.get('useMontages', []) skill_data['montageData'] = [] for montage_path in use_montages: # '/Script/Engine.AnimMontage'/Game/_Art/.../ AM_XXX.AM_XXX' -> 'AM_XXX' montage_name = montage_path.split('/')[-1].replace("'", "").split('.')[0] if montage_name in anim_montages: skill_data['montageData'].append(anim_montages[montage_name]) else: print(f" [WARN] {skill_id}: 몽타주 {montage_name} 정보 없음") # 바란 궁극기 특수 처리: AN_SimpleSendEvent 시점을 castingTime으로 사용 if skill_id == 'SK130301': # 바란 궁극기 '일격분쇄' for montage_data in skill_data['montageData']: for notify in montage_data.get('allNotifies', []): if 'SimpleSendEvent' in notify.get('NotifyClass', ''): event_tag = notify.get('CustomProperties', {}).get('Event Tag', '') if 'Ability.Attack.Ready' in event_tag: trigger_time = notify.get('TriggerTime', 0) skill_data['castingTime'] = round(trigger_time, 2) print(f" [INFO] {skill_id}: castingTime 오버라이드 {skill_data['castingTime']}초 (AN_SimpleSendEvent)") break # DoT 스킬 체크 skill_data['isDot'] = skill_id in config.DOT_SKILLS # 소환수 스킬 체크 (유틸리티 판별보다 먼저 설정) skill_data['isSummon'] = skill_id in config.SUMMON_SKILLS if skill_data['isSummon']: summon_info = config.SUMMON_SKILLS.get(skill_id, {}) summon_type = summon_info.get('type', 'npc') if summon_type == 'special': # Shiva 특수 처리: 직접 몽타주 지정 skill_data['summonMontageData'] = [] montage_name = summon_info.get('montage') attack_interval_bonus = summon_info.get('attack_interval_bonus', 0) if montage_name and montage_name in anim_montages: montage_info = anim_montages[montage_name].copy() # 공격 주기 = 실제 시간 + 보너스 original_duration = montage_info['actualDuration'] montage_info['attackInterval'] = original_duration + attack_interval_bonus skill_data['summonMontageData'].append(montage_info) skill_data['summonType'] = 'special' else: # Ifrit 등: DT_NPCAbility에서 추출 summon_name = summon_info.get('summon', '').lower() if summon_name in npc_abilities: npc_data = npc_abilities[summon_name] attack_map = npc_data.get('attackMontageMap', {}) skill_data['summonAttackMap'] = attack_map # 소환수 몽타주 데이터 추가 skill_data['summonMontageData'] = [] for weapon_type, montage_data in attack_map.items(): montage_array = montage_data.get('montageArray', []) for montage_path in montage_array: montage_name = montage_path.split('/')[-1].replace("'", "").split('.')[0] if montage_name in anim_montages: skill_data['summonMontageData'].append(anim_montages[montage_name]) skill_data['summonType'] = 'npc' # 유틸리티 스킬 판별 (isSummon 설정 이후에 실행) skill_data['isUtility'] = is_utility_skill(skill_data) skills[skill_id] = skill_data # 평타 몽타주 상세 정보 매칭 attack_montage_map = abilities.get('attackMontageMap', {}) basic_attacks = {} for weapon_type, montage_data in attack_montage_map.items(): montage_array = montage_data.get('montageArray', []) basic_attacks[weapon_type] = [] for idx, montage_path in enumerate(montage_array): montage_name = montage_path.split('/')[-1].replace("'", "").split('.')[0] if montage_name in anim_montages: montage_info = anim_montages[montage_name] # 평타는 ANS_AttackState 종료 시점을 우선 사용 # 없으면 actualDuration 폴백 attack_state_end = montage_info.get('attackStateEndTime') effective_duration = attack_state_end if attack_state_end is not None else montage_info['actualDuration'] basic_attacks[weapon_type].append({ 'index': idx + 1, 'montageName': montage_name, 'sequenceLength': montage_info['sequenceLength'], 'rateScale': montage_info['rateScale'], 'actualDuration': montage_info['actualDuration'], # 원본 몽타주 시간 'attackStateEndTime': attack_state_end, # ANS_AttackState 종료 시점 'effectiveDuration': effective_duration, # 실제 평타 시간 (ANS_AttackState 우선) 'attackMultiplier': montage_info['attackMultiplier'], 'hasAttack': montage_info['hasAttack'] }) # 소환체 데이터 생성 (레네만) summons = {} for skill_id, skill_data in skills.items(): if skill_data.get('isSummon'): summon_config = config.SUMMON_SKILLS.get(skill_id, {}) summon_name = summon_config.get('summon', 'Unknown') # 공격 몽타주 정보 추출 attack_montages = [] for montage_data in skill_data.get('summonMontageData', []): attack_montages.append({ 'montageName': montage_data.get('assetName', 'N/A'), 'actualDuration': montage_data.get('actualDuration', 0) }) summons[summon_name] = { 'summonSkillId': skill_id, 'summonSkillName': skill_data.get('name', ''), 'activeDuration': skill_data.get('activeDuration', 0), 'skillDamageRate': skill_data.get('skillDamageRate', 0), # 피해 배율 추가 'attackMontages': attack_montages, 'dotType': config.DOT_SKILLS.get(skill_id, {}).get('dot_type', '') } organized[stalker_id] = { 'id': stalker_id, 'stats': stats, 'abilities': abilities, 'basicAttacks': basic_attacks, # 평타 상세 정보 'skills': skills, 'defaultSkills': [skills.get(sid) for sid in stats['defaultSkills'] if sid in skills], 'subSkill': skills.get(stats['subSkill']), 'ultimateSkill': skills.get(stats['ultimateSkill']), 'summons': summons # 소환체 정보 } skill_count = len(skills) print(f" [OK] {stats['name']} ({stalker_id}): {skill_count}개 스킬") # 공통 데이터 추가 organized['_metadata'] = { 'runes': runes, 'runeGroups': rune_groups, 'equipment': equipment, 'gearScoreConstants': float_constants } return organized def is_utility_skill(skill_data: Dict) -> bool: """ 유틸리티 스킬 판별 (DPS 계산 제외 대상) 판별 기준: 1. config.UTILITY_SKILLS에 명시적으로 등록 2. skillAttackType == "Normal" AND skillDamageRate == 0 3. 몽타주에 공격 노티파이 없음 (montageData 확인) 예외: 소환 스킬은 항상 공격 스킬로 간주 """ skill_id = skill_data['skillId'] # 소환 스킬은 공격 스킬 (유틸리티 아님) if skill_data.get('isSummon', False): return False # 1. 수동 지정 if skill_id in config.UTILITY_SKILLS: return True # 2. Normal 타입 + Rate 0 if skill_data['skillAttackType'] == 'Normal' and skill_data['skillDamageRate'] == 0: return True # 3. 몽타주에 공격 노티파이 없음 montage_data_list = skill_data.get('montageData', []) if montage_data_list: has_attack = any(m.get('hasAttack', False) for m in montage_data_list) if not has_attack and skill_data['skillDamageRate'] > 0: # Rate는 있지만 공격 노티파이 없음 -> 유틸리티 return True return False def main(): """메인 실행 함수""" print("="*80) print("스토커 데이터 통합 추출 v2") print("="*80) # 1. JSON 파일 로드 print("\n[ JSON 파일 로드 ]") datatable_data = load_json(config.DATATABLE_JSON) blueprint_data = load_json(config.BLUEPRINT_JSON) animmontage_data = load_json(config.ANIMMONTAGE_JSON) datatables = datatable_data.get('Assets', []) blueprints = blueprint_data.get('Assets', []) montages = animmontage_data.get('Assets', []) # 2. 데이터 추출 stalker_stats = extract_character_stats(datatables) stalker_abilities = extract_character_abilities(datatables) all_skills = extract_skills(datatables) skill_blueprints = extract_skill_blueprints(blueprints) anim_montages = extract_anim_montages(montages) npc_abilities = extract_npc_abilities(datatables) # 소환수 데이터 runes = extract_runes(datatables) # 룬 데이터 rune_groups = extract_rune_groups(datatables) # 룬 그룹 데이터 equipment = extract_equipment(datatables) # 장비 데이터 float_constants = extract_float_constants(datatables) # 기어스코어 상수 # 3. 데이터 통합 organized_data = organize_stalker_data( stalker_stats, stalker_abilities, all_skills, skill_blueprints, anim_montages, npc_abilities, runes, rune_groups, equipment, float_constants ) # 4. 결과 저장 (새 디렉토리 생성) output_dir = config.get_output_dir(create_new=True) output_dir.mkdir(parents=True, exist_ok=True) output_file = output_dir / "intermediate_data.json" with open(output_file, 'w', encoding='utf-8') as f: json.dump(organized_data, f, ensure_ascii=False, indent=2) print(f"\n[OK] 중간 데이터 저장 완료: {output_file}") print(f" - 출력 디렉토리: {output_dir}") print(f" - 총 {len(organized_data)}명 스토커 데이터") return organized_data if __name__ == "__main__": main()