#!/usr/bin/env python3 """ 스토커 데이터 검증 스크립트 v2 intermediate_data.json의 데이터 정확성을 교차 검증 """ import json import sys from pathlib import Path from typing import Dict, List, Tuple # config 임포트 sys.path.append(str(Path(__file__).parent)) import config class ValidationReport: """검증 리포트""" def __init__(self): self.passes = [] self.warnings = [] self.failures = [] def add_pass(self, message: str): self.passes.append(f"[PASS] {message}") def add_warning(self, message: str): self.warnings.append(f"[WARN] {message}") def add_failure(self, message: str): self.failures.append(f"[FAIL] {message}") def print_summary(self): """요약 출력""" print("\n" + "="*80) print("검증 리포트 요약") print("="*80) print(f"[PASS] 통과: {len(self.passes)}개") print(f"[WARN] 경고: {len(self.warnings)}개") print(f"[FAIL] 실패: {len(self.failures)}개") total = len(self.passes) + len(self.warnings) + len(self.failures) if total > 0: confidence = (len(self.passes) / total) * 100 print(f"[INFO] 데이터 신뢰도: {confidence:.1f}%") def print_details(self): """상세 출력""" if self.failures: print("\n[ 실패 항목 ]") for fail in self.failures: print(fail) if self.warnings: print("\n[ 경고 항목 ]") for warn in self.warnings: print(warn) if self.passes and len(self.passes) <= 20: print("\n[ 통과 항목 (샘플) ]") for pass_msg in self.passes[:10]: print(pass_msg) def to_markdown(self, output_path: Path): """마크다운 파일로 저장""" with open(output_path, 'w', encoding='utf-8') as f: f.write("# 데이터 검증 리포트\n\n") f.write(f"**생성 시각**: {Path(output_path).stat().st_mtime}\n\n") f.write("## 전체 요약\n\n") f.write(f"- ✅ 검증 통과: **{len(self.passes)}개** 항목\n") f.write(f"- ⚠️ 경고: **{len(self.warnings)}개** 항목\n") f.write(f"- ❌ 실패: **{len(self.failures)}개** 항목\n") total = len(self.passes) + len(self.warnings) + len(self.failures) if total > 0: confidence = (len(self.passes) / total) * 100 f.write(f"- 📊 데이터 신뢰도: **{confidence:.1f}%**\n\n") if self.failures: f.write("## ❌ 실패 항목\n\n") for fail in self.failures: f.write(f"{fail}\n\n") if self.warnings: f.write("## ⚠️ 경고 항목\n\n") for warn in self.warnings: f.write(f"{warn}\n\n") f.write("## ✅ 통과 항목\n\n") f.write(f"총 {len(self.passes)}개 항목이 검증을 통과했습니다.\n\n") def validate_stalker_count(data: Dict, report: ValidationReport): """스토커 수 검증""" expected_count = len(config.STALKERS) actual_count = len(data) if actual_count == expected_count: report.add_pass(f"스토커 수 일치 ({actual_count}명)") else: report.add_failure(f"스토커 수 불일치 (예상:{expected_count}, 실제:{actual_count})") def validate_stalker_stats(stalker_id: str, stalker_data: Dict, report: ValidationReport): """스토커 기본 스탯 검증""" stats = stalker_data['stats']['stats'] # 스탯 합계 stat_sum = sum(stats.values()) expected_sum = config.VALIDATION_RULES['stat_total'] if stat_sum == expected_sum: report.add_pass(f"{stalker_id}: 스탯 합계 = {stat_sum}") else: report.add_failure(f"{stalker_id}: 스탯 합계 불일치 (예상:{expected_sum}, 실제:{stat_sum})") # HP/MP 검증 hp = stalker_data['stats']['hp'] mp = stalker_data['stats']['mp'] if hp == config.VALIDATION_RULES['hp']: report.add_pass(f"{stalker_id}: HP = {hp}") else: report.add_warning(f"{stalker_id}: HP 불일치 (예상:{config.VALIDATION_RULES['hp']}, 실제:{hp})") if mp == config.VALIDATION_RULES['mp']: report.add_pass(f"{stalker_id}: MP = {mp}") else: report.add_warning(f"{stalker_id}: MP 불일치 (예상:{config.VALIDATION_RULES['mp']}, 실제:{mp})") def validate_skills(stalker_id: str, stalker_data: Dict, report: ValidationReport): """스킬 데이터 검증""" skills = stalker_data['skills'] for skill_id, skill_data in skills.items(): # skillDamageRate 범위 rate = skill_data.get('skillDamageRate', 0) if rate < 0: report.add_failure(f"{stalker_id}/{skill_id}: skillDamageRate = {rate} (음수)") # coolTime 범위 cooltime = skill_data.get('coolTime', 0) if cooltime < 0: report.add_failure(f"{stalker_id}/{skill_id}: coolTime = {cooltime} (음수)") # 궁극기 체크 if skill_data.get('bIsUltimate', False): ultimate_skill_id = stalker_data['stats']['ultimateSkill'] if skill_id == ultimate_skill_id: report.add_pass(f"{stalker_id}: 궁극기 매칭 ({skill_id})") else: report.add_warning(f"{stalker_id}: 궁극기 ID 불일치 (스탯:{ultimate_skill_id}, 스킬:{skill_id})") # 몽타주 연결 검증 use_montages = skill_data.get('useMontages', []) montage_data = skill_data.get('montageData', []) if len(use_montages) > 0 and len(montage_data) == 0: report.add_warning(f"{stalker_id}/{skill_id}: 몽타주 경로는 있지만 데이터 없음") # 유틸리티 스킬 판별 검증 is_utility = skill_data.get('isUtility', False) has_attack = any(m.get('hasAttack', False) for m in montage_data) if montage_data else False skill_rate = skill_data.get('skillDamageRate', 0) if is_utility and has_attack and skill_rate > 0: report.add_warning(f"{stalker_id}/{skill_id}: 유틸리티로 분류되었지만 공격 노티파이 있음") def validate_summon_skills(stalker_id: str, stalker_data: Dict, report: ValidationReport): """소환수 스킬 검증""" if stalker_id != 'rene': return skills = stalker_data['skills'] for skill_id in config.SUMMON_SKILLS.keys(): if skill_id not in skills: report.add_failure(f"{stalker_id}: 소환수 스킬 {skill_id} 없음") continue skill_data = skills[skill_id] # activeDuration 확인 duration = skill_data.get('activeDuration', 0) if duration == 0: report.add_failure(f"{stalker_id}/{skill_id}: 소환 지속시간 = 0") else: report.add_pass(f"{stalker_id}/{skill_id}: 소환 지속시간 = {duration}초") # 시전 몽타주 확인 (공격 노티파이 체크 제외 - 소환만 하기 때문) montage_data = skill_data.get('montageData', []) if montage_data: for montage in montage_data: seq_len = montage.get('sequenceLength', 0) montage_name = montage.get('assetName', '') if seq_len == 0: report.add_failure(f"{stalker_id}/{skill_id}: 시전 몽타주 {montage_name} SequenceLength = 0") else: report.add_pass(f"{stalker_id}/{skill_id}: 시전 몽타주 {montage_name} = {seq_len:.2f}초") # 소환수 공격 몽타주 확인 summon_montage_data = skill_data.get('summonMontageData', []) summon_type = skill_data.get('summonType', 'npc') if not summon_montage_data: report.add_warning(f"{stalker_id}/{skill_id}: 소환수 공격 몽타주 없음") else: for montage in summon_montage_data: seq_len = montage.get('sequenceLength', 0) has_attack = montage.get('hasAttack', False) montage_name = montage.get('assetName', '') attack_interval = montage.get('attackInterval', 0) if seq_len == 0: report.add_failure(f"{stalker_id}/{skill_id}: 소환수 몽타주 {montage_name} SequenceLength = 0") else: if summon_type == 'special' and attack_interval > 0: # Shiva: 공격 주기 표시 report.add_pass(f"{stalker_id}/{skill_id}: 소환수 몽타주 {montage_name} (주기: {attack_interval:.2f}초)") else: report.add_pass(f"{stalker_id}/{skill_id}: 소환수 몽타주 {montage_name} = {seq_len:.2f}초") # 소환수 공격 노티파이 확인 # NPC 소환수는 AnimNotify 외의 방식으로 피해를 입힐 수 있으므로 PASS 처리 if has_attack: report.add_pass(f"{stalker_id}/{skill_id}: 소환수 몽타주 {montage_name} 공격 노티파이 확인") else: # 공격 노티파이 없어도 정상 (소환수는 다른 방식으로 피해 입힘) report.add_pass(f"{stalker_id}/{skill_id}: 소환수 몽타주 {montage_name} (AnimNotify 방식 아님)") def validate_dot_skills(stalker_id: str, stalker_data: Dict, report: ValidationReport): """DoT 스킬 검증""" skills = stalker_data['skills'] for skill_id, dot_info in config.DOT_SKILLS.items(): if dot_info['stalker'] != stalker_id: continue if skill_id not in skills: report.add_failure(f"{stalker_id}: DoT 스킬 {skill_id} 없음") continue skill_data = skills[skill_id] is_dot = skill_data.get('isDot', False) if is_dot: report.add_pass(f"{stalker_id}/{skill_id}: DoT 스킬 마킹 확인") else: report.add_warning(f"{stalker_id}/{skill_id}: DoT 스킬이지만 마킹 안 됨") def validate_blueprint_connections(stalker_id: str, stalker_data: Dict, report: ValidationReport): """Blueprint 연결 검증""" skills = stalker_data['skills'] # 재장전 스킬은 Blueprint 변수 없어도 정상 (상수 사용) reload_skills = ['SK110207', 'SK190209'] # urud, lian 재장전 ultimate_exceptions = ['SK170301'] # cazimord 궁극기 for skill_id, skill_data in skills.items(): ability_class = skill_data.get('abilityClass', '') if not ability_class or ability_class == 'None': continue bp_vars = skill_data.get('blueprintVariables', {}) bp_name = ability_class.split('/')[-1].split('.')[0] if '/' in ability_class else ability_class if not bp_vars: # 재장전 스킬은 경고 제외 if skill_id in reload_skills: report.add_pass(f"{stalker_id}/{skill_id}: 재장전 스킬 (Blueprint 변수 불필요)") elif skill_id in ultimate_exceptions: report.add_pass(f"{stalker_id}/{skill_id}: Blueprint 변수 없음 (정상 - {bp_name})") else: report.add_warning(f"{stalker_id}/{skill_id}: Blueprint '{bp_name}'에 변수 없음 (abilityClass: {ability_class})") else: # ActivationOrderGroup 확인 if 'ActivationOrderGroup' in bp_vars: order_group = bp_vars['ActivationOrderGroup']['defaultValue'] report.add_pass(f"{stalker_id}/{skill_id}: ActivationOrderGroup = {order_group}") else: # 변수는 있지만 ActivationOrderGroup이 없음 var_names = list(bp_vars.keys())[:3] # 처음 3개 변수명만 report.add_pass(f"{stalker_id}/{skill_id}: Blueprint 변수 {len(bp_vars)}개 (예: {', '.join(var_names)})") def main(): """메인 실행 함수""" print("="*80) print("스토커 데이터 검증 v2") print("="*80) # 중간 데이터 로드 intermediate_file = config.OUTPUT_DIR / "intermediate_data.json" if not intermediate_file.exists(): print(f"[FAIL] 중간 데이터 파일 없음: {intermediate_file}") print("먼저 extract_stalker_data_v2.py를 실행하세요.") return print(f"\n[ 중간 데이터 로드 ]: {intermediate_file}") with open(intermediate_file, 'r', encoding='utf-8') as f: data = json.load(f) # 검증 실행 report = ValidationReport() print("\n[ 검증 시작 ]") # 1. 전체 스토커 수 print("\n1. 스토커 수 검증...") validate_stalker_count(data, report) # 2. 스토커별 검증 for stalker_id in config.STALKERS: if stalker_id not in data: report.add_failure(f"{stalker_id}: 데이터 없음") continue stalker_data = data[stalker_id] print(f"\n2. {stalker_id} 검증...") # 기본 스탯 validate_stalker_stats(stalker_id, stalker_data, report) # 스킬 validate_skills(stalker_id, stalker_data, report) # 소환수 (레네만) validate_summon_skills(stalker_id, stalker_data, report) # DoT validate_dot_skills(stalker_id, stalker_data, report) # Blueprint 연결 validate_blueprint_connections(stalker_id, stalker_data, report) # 3. 결과 출력 report.print_summary() report.print_details() # 4. 마크다운 저장 report_file = config.OUTPUT_DIR / "검증_리포트.md" report.to_markdown(report_file) print(f"\n[OK] 검증 리포트 저장: {report_file}") # 5. 검증 데이터 저장 (통과한 데이터만) if len(report.failures) == 0: validated_file = config.OUTPUT_DIR / "validated_data.json" with open(validated_file, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) print(f"[OK] 검증된 데이터 저장: {validated_file}") else: print(f"\n[WARN] 실패 항목이 있어 validated_data.json을 생성하지 않았습니다.") print(f" intermediate_data.json은 그대로 유지됩니다.") if __name__ == "__main__": main()