572 lines
21 KiB
Python
572 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
스토커 기본 데이터 문서 생성 스크립트 v2
|
|
|
|
validated_data.json (또는 intermediate_data.json)에서
|
|
03_스토커별_기본데이터_v2.md 생성
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
from datetime import datetime
|
|
|
|
# config 임포트
|
|
sys.path.append(str(Path(__file__).parent))
|
|
import config
|
|
|
|
def generate_header() -> str:
|
|
"""문서 헤더 생성"""
|
|
return f"""# 03. 스토커별 기본 데이터 (v2)
|
|
|
|
## 데이터 소스
|
|
- `DT_CharacterStat`: 기본 스탯, 스킬 목록
|
|
- `DT_CharacterAbility`: 평타 몽타주
|
|
- `DT_Skill`: 스킬 상세 정보 (이름, 피해배율, 쿨타임, 마나, 시전시간, 효과)
|
|
- `Blueprint`: 스킬 변수 (ActivationOrderGroup 등)
|
|
- `AnimMontage`: 타이밍 및 공격 노티파이, 실제 발사 시점
|
|
|
|
## 검증 상태
|
|
- ✅ 모든 데이터는 최신 JSON (2025-10-24 15:58:55)에서 추출
|
|
- ✅ 교차 검증 완료
|
|
- ✅ 출처 명시 (각 데이터 필드별)
|
|
|
|
## DPS 계산 시 고려사항
|
|
- **시전시간**: 스킬 사용 시 시전시간(CastingTime)이 추가됨
|
|
- **실제 공격 시점**: 원거리 스킬(우르드, 리안)의 경우 몽타주 시간보다 빠르게 공격 가능
|
|
- **DoT 데미지**: DoT(Damage over Time) 스킬은 대상 HP에 비례하여 지속 피해 발생 (구체적 계산은 다음 챕터 참조)
|
|
|
|
---
|
|
|
|
"""
|
|
|
|
def generate_stalker_overview(data: Dict) -> str:
|
|
"""10명 스토커 종합 비교표"""
|
|
md = "## 10명 스토커 종합 비교표\n\n"
|
|
md += "| 스토커 | 직업 | STR | DEX | INT | CON | WIS | 궁극기 | 장착 무기 | 평타 |\n"
|
|
md += "|--------|------|-----|-----|-----|-----|-----|--------|-----------|------|\n"
|
|
|
|
for stalker_id in config.STALKERS:
|
|
if stalker_id not in data:
|
|
continue
|
|
|
|
stalker = data[stalker_id]
|
|
stats = stalker['stats']
|
|
st = stats['stats']
|
|
|
|
# 궁극기
|
|
has_ultimate = "⭐" if stats['ultimateSkill'] else ""
|
|
|
|
# 장착 무기
|
|
equip_types = ', '.join(stats['equipableTypes'])
|
|
|
|
# 평타 콤보
|
|
attack_map = stalker['abilities'].get('attackMontageMap', {})
|
|
combo_counts = []
|
|
for weapon_type, montage_data in attack_map.items():
|
|
count = len(montage_data.get('montageArray', []))
|
|
combo_counts.append(f"{count}타")
|
|
combo_str = ', '.join(combo_counts) if combo_counts else "N/A"
|
|
|
|
md += f"| **{stats['name']}** | {stats['jobName']} | {st['str']} | {st['dex']} | {st['int']} | {st['con']} | {st['wis']} | {has_ultimate} | {equip_types} | {combo_str} |\n"
|
|
|
|
md += "\n**특징**:\n"
|
|
md += "- **모든 스토커가 궁극기 보유**\n"
|
|
md += "- 모든 스토커 스탯 합계: 75 포인트 (균형)\n"
|
|
md += "- HP/MP 동일: 100/50\n"
|
|
md += "- 마나 회복: 0.2/초 (전원 동일)\n\n"
|
|
md += "---\n\n"
|
|
|
|
return md
|
|
|
|
def generate_ultimate_overview(data: Dict) -> str:
|
|
"""궁극기 종합 비교"""
|
|
md = "## 궁극기 종합 비교\n\n"
|
|
md += "| 스토커 | 궁극기 이름 | 타입 | 피해배율 | 지속/시전 | 주요 효과 |\n"
|
|
md += "|--------|-------------|------|----------|-----------|----------|\n"
|
|
|
|
for stalker_id in config.STALKERS:
|
|
if stalker_id not in data:
|
|
continue
|
|
|
|
stalker = data[stalker_id]
|
|
ultimate_skill = stalker.get('ultimateSkill')
|
|
|
|
if not ultimate_skill:
|
|
continue
|
|
|
|
name = ultimate_skill.get('name', 'N/A')
|
|
skill_type = ultimate_skill.get('skillAttackType', 'Normal')
|
|
damage_rate = ultimate_skill.get('skillDamageRate', 0)
|
|
active_duration = ultimate_skill.get('activeDuration', 0)
|
|
casting_time = ultimate_skill.get('castingTime', 0)
|
|
|
|
# 설명 (descFormatted 사용 - 변수 치환 및 줄바꿈 제거됨)
|
|
desc = ultimate_skill.get('descFormatted', ultimate_skill.get('simpleDesc', ''))[:100]
|
|
|
|
stalker_name = stalker['stats']['name']
|
|
|
|
md += f"| **{stalker_name}** | {name} | {skill_type} | {damage_rate} | {active_duration}초 / {casting_time}초 | {desc}... |\n"
|
|
|
|
md += "\n---\n\n"
|
|
|
|
return md
|
|
|
|
def generate_dot_overview(data: Dict) -> str:
|
|
"""DoT 스킬 종합 비교"""
|
|
md = "## DoT 스킬 종합 비교\n\n"
|
|
md += "다음 스킬들은 DoT(Damage over Time) 효과가 있으며, **DPS 계산 시 추가 지속 피해를 고려해야 합니다**.\n\n"
|
|
md += "| 스토커 | 스킬 이름 | DoT 타입 | 기본 피해 | DoT 피해 | 지속시간 |\n"
|
|
md += "|--------|----------|----------|----------|----------|----------|\n"
|
|
|
|
# config.DOT_SKILLS에서 DoT 스킬 정보 가져오기
|
|
for skill_id, dot_info in config.DOT_SKILLS.items():
|
|
stalker_id = dot_info['stalker']
|
|
if stalker_id not in data:
|
|
continue
|
|
|
|
stalker = data[stalker_id]
|
|
stalker_name = stalker['stats']['name']
|
|
skills = stalker.get('skills', {})
|
|
|
|
if skill_id not in skills:
|
|
continue
|
|
|
|
skill = skills[skill_id]
|
|
skill_name = skill.get('name', 'N/A')
|
|
dot_type = dot_info.get('dot_type', 'DoT')
|
|
damage_rate = skill.get('skillDamageRate', 0)
|
|
|
|
# DoT 피해 설명
|
|
if dot_type == 'Poison':
|
|
dot_damage = "대상 MaxHP의 20%"
|
|
duration = "5초"
|
|
elif dot_type == 'Burn':
|
|
dot_damage = "대상 MaxHP의 10%"
|
|
duration = "3초"
|
|
elif dot_type == 'Bleed':
|
|
dot_damage = "고정 20 피해"
|
|
duration = "5초"
|
|
else:
|
|
dot_damage = "N/A"
|
|
duration = "N/A"
|
|
|
|
md += f"| **{stalker_name}** | {skill_name} | {dot_type} | {damage_rate} | {dot_damage} | {duration} |\n"
|
|
|
|
md += "\n**주의사항**:\n"
|
|
md += "- DoT 피해는 대상의 HP에 비례하므로, 적의 체력에 따라 실제 피해량이 달라집니다.\n"
|
|
md += "- 구체적인 DoT DPS 계산 방법은 다음 챕터에서 다룹니다.\n"
|
|
md += "- 위 표의 '기본 피해'는 스킬의 skillDamageRate입니다.\n\n"
|
|
md += "---\n\n"
|
|
|
|
return md
|
|
|
|
def get_montage_tag(montage_name: str) -> str:
|
|
"""
|
|
몽타주 이름에서 태그 추출
|
|
|
|
Args:
|
|
montage_name: 몽타주 이름
|
|
|
|
Returns:
|
|
태그 문자열 (예: "[준비]", "[장비]") 또는 빈 문자열
|
|
"""
|
|
montage_tags = config.SEQUENCE_CALCULATION_RULES.get('montage_tags', {})
|
|
exclude_keywords = config.SEQUENCE_CALCULATION_RULES.get('exclude_keywords', [])
|
|
|
|
for keyword in exclude_keywords:
|
|
if keyword.lower() in montage_name.lower():
|
|
return montage_tags.get(keyword, '')
|
|
|
|
return ''
|
|
|
|
def calculate_sequence_length(skill_id: str, montage_data: List[Dict]) -> tuple:
|
|
"""
|
|
스킬의 시퀀스 길이 계산
|
|
|
|
Args:
|
|
skill_id: 스킬 ID
|
|
montage_data: 몽타주 데이터 리스트
|
|
|
|
Returns:
|
|
(sequence_length, is_average, included_montages)
|
|
- sequence_length: 계산된 시퀀스 길이
|
|
- is_average: 평균 계산 여부
|
|
- included_montages: 계산에 포함된 몽타주 리스트 (인덱스)
|
|
"""
|
|
if not montage_data:
|
|
return 0, False, []
|
|
|
|
rules = config.SEQUENCE_CALCULATION_RULES
|
|
exclude_keywords = rules.get('exclude_keywords', [])
|
|
average_skills = rules.get('average_skills', [])
|
|
exclude_montages = rules.get('exclude_montages', {})
|
|
exclude_montage_indices = rules.get('exclude_montage_indices', {})
|
|
|
|
# 1. 특정 몽타주 제외 리스트 가져오기
|
|
skill_exclude_list = exclude_montages.get(skill_id, [])
|
|
skill_exclude_indices = exclude_montage_indices.get(skill_id, [])
|
|
|
|
# 2. 포함될 몽타주 필터링
|
|
included_montages = []
|
|
for idx, montage in enumerate(montage_data):
|
|
montage_name = montage.get('assetName', '')
|
|
|
|
# 인덱스로 제외 체크
|
|
if idx in skill_exclude_indices:
|
|
continue
|
|
|
|
# 특정 몽타주 제외 체크
|
|
if montage_name in skill_exclude_list:
|
|
continue
|
|
|
|
# 키워드 제외 체크 (대소문자 구분 없음)
|
|
has_exclude_keyword = any(
|
|
keyword.lower() in montage_name.lower()
|
|
for keyword in exclude_keywords
|
|
)
|
|
|
|
if not has_exclude_keyword:
|
|
included_montages.append(idx)
|
|
|
|
# 3. 포함된 몽타주가 없으면 0 반환
|
|
if not included_montages:
|
|
return 0, False, []
|
|
|
|
# 4. 시퀀스 길이 계산
|
|
is_average = skill_id in average_skills
|
|
|
|
if is_average:
|
|
# 평균 계산
|
|
total = sum(montage_data[idx].get('actualDuration', 0) for idx in included_montages)
|
|
sequence_length = total / len(included_montages) if included_montages else 0
|
|
else:
|
|
# 합산 계산
|
|
sequence_length = sum(montage_data[idx].get('actualDuration', 0) for idx in included_montages)
|
|
|
|
return sequence_length, is_average, included_montages
|
|
|
|
def generate_stalker_detail(stalker_id: str, stalker_data: Dict) -> str:
|
|
"""개별 스토커 상세 정보"""
|
|
stats = stalker_data['stats']
|
|
st = stats['stats']
|
|
info = config.STALKER_INFO.get(stalker_id, {})
|
|
|
|
# stats['name']은 이미 "English (Korean)" 형식
|
|
md = f"## {config.STALKERS.index(stalker_id) + 1}. {stats['name']} - {info.get('role', stats['jobName'])}\n\n"
|
|
|
|
# 기본 정보
|
|
md += "### 기본 정보\n"
|
|
md += f"- **역할**: {info.get('role', 'N/A')}\n"
|
|
md += f"- **주 스탯**: "
|
|
|
|
# 주 스탯 찾기 (가장 높은 2개)
|
|
stat_pairs = [(k.upper(), v) for k, v in st.items()]
|
|
stat_pairs.sort(key=lambda x: x[1], reverse=True)
|
|
md += f"{stat_pairs[0][0]} {stat_pairs[0][1]}, {stat_pairs[1][0]} {stat_pairs[1][1]}\n"
|
|
|
|
md += f"- **HP**: {stats['hp']} | **MP**: {stats['mp']} | **마나 회복**: {stats['manaRegen']}/초\n"
|
|
|
|
# 크리티컬 스탯
|
|
crit_per = stats.get('criticalPer', 5)
|
|
crit_dmg = stats.get('criticalDamage', 0)
|
|
md += f"- **크리티컬**: 확률 {crit_per}% | 추가 피해 {crit_dmg}%\n"
|
|
|
|
# 장착 무기
|
|
equip_types = ', '.join(stats['equipableTypes'])
|
|
md += f"- **장착 가능**: {equip_types}\n"
|
|
|
|
# 평타
|
|
attack_map = stalker_data['abilities'].get('attackMontageMap', {})
|
|
if attack_map:
|
|
combo_info = []
|
|
for weapon_type, montage_data in attack_map.items():
|
|
count = len(montage_data.get('montageArray', []))
|
|
combo_info.append(f"{weapon_type} {count}타")
|
|
md += f"- **평타**: {', '.join(combo_info)}\n"
|
|
|
|
md += "\n"
|
|
|
|
# 평타 상세 정보
|
|
basic_attacks = stalker_data.get('basicAttacks', {})
|
|
if basic_attacks:
|
|
md += "### 평타 상세 정보\n\n"
|
|
for weapon_type, attacks in basic_attacks.items():
|
|
if attacks:
|
|
md += f"**{weapon_type}** ({len(attacks)}타 콤보):\n\n"
|
|
md += "| 타수 | 몽타주 | 시간(초) | 배율(%) | 비고 |\n"
|
|
md += "|------|--------|----------|---------|------|\n"
|
|
for attack in attacks:
|
|
idx = attack['index']
|
|
montage_name = attack['montageName']
|
|
duration = attack['actualDuration']
|
|
multiplier = attack['attackMultiplier']
|
|
mult_display = f"{multiplier:+.1f}" if multiplier != 0 else "0.0"
|
|
# 태그 추가
|
|
tag = get_montage_tag(montage_name)
|
|
note = tag if tag else ""
|
|
md += f"| {idx} | {montage_name} | {duration:.2f} | {mult_display} | {note} |\n"
|
|
md += "\n"
|
|
|
|
# 기본 스킬
|
|
md += "### 스킬 목록\n\n"
|
|
md += "**기본 스킬**:\n\n"
|
|
|
|
default_skills = stalker_data.get('defaultSkills', [])
|
|
for idx, skill in enumerate(default_skills, 1):
|
|
if not skill:
|
|
continue
|
|
md += generate_skill_entry(skill, idx)
|
|
|
|
# 서브 스킬
|
|
sub_skill = stalker_data.get('subSkill')
|
|
if sub_skill:
|
|
md += "\n**서브 스킬**:\n\n"
|
|
md += generate_skill_entry(sub_skill, 0, is_sub=True)
|
|
|
|
# 궁극기
|
|
ultimate_skill = stalker_data.get('ultimateSkill')
|
|
if ultimate_skill:
|
|
md += "\n**궁극기**:\n\n"
|
|
md += generate_skill_entry(ultimate_skill, 0, is_ultimate=True)
|
|
|
|
# 소환체 (레네만)
|
|
summons = stalker_data.get('summons', {})
|
|
if summons:
|
|
md += "\n### 소환체\n\n"
|
|
for summon_name, summon_data in summons.items():
|
|
md += generate_summon_entry(summon_name, summon_data)
|
|
|
|
md += "\n---\n\n"
|
|
|
|
return md
|
|
|
|
def generate_summon_entry(summon_name: str, summon_data: Dict) -> str:
|
|
"""소환체 엔트리 생성"""
|
|
summon_skill_id = summon_data.get('summonSkillId', 'N/A')
|
|
summon_skill_name = summon_data.get('summonSkillName', 'N/A')
|
|
active_duration = summon_data.get('activeDuration', 0)
|
|
skill_damage_rate = summon_data.get('skillDamageRate', 0)
|
|
attack_montages = summon_data.get('attackMontages', [])
|
|
dot_type = summon_data.get('dotType', '')
|
|
|
|
# 소환체 타입별 아이콘
|
|
icon = ''
|
|
if 'ifrit' in summon_name.lower() or '화염' in summon_skill_name:
|
|
icon = '🔥'
|
|
elif 'shiva' in summon_name.lower() or '냉기' in summon_skill_name or '얼음' in summon_skill_name:
|
|
icon = '❄️'
|
|
|
|
md = f"#### {icon} {summon_name}\n\n"
|
|
md += f"- **소환 스킬**: {summon_skill_id} {summon_skill_name}\n"
|
|
|
|
if active_duration > 0:
|
|
md += f"- **소환 유지 시간**: {active_duration}초\n"
|
|
|
|
# 공격 몽타주 정보 및 DPS 계산
|
|
if attack_montages:
|
|
md += f"- **공격 몽타주**: \n"
|
|
|
|
# 공격 사이클 계산 (순차적 반복)
|
|
total_cycle_time = 0
|
|
montage_durations = []
|
|
for montage in attack_montages:
|
|
montage_name = montage.get('montageName', 'N/A')
|
|
duration = montage.get('actualDuration', 0)
|
|
md += f" - {montage_name} ({duration:.2f}초)\n"
|
|
total_cycle_time += duration
|
|
montage_durations.append(duration)
|
|
|
|
# 공격 사이클 및 DPS 계산
|
|
if len(attack_montages) > 0 and total_cycle_time > 0:
|
|
# 공격 사이클 표시
|
|
if len(attack_montages) == 1:
|
|
# 몽타주 1개
|
|
md += f"- **공격 사이클**: {montage_durations[0]:.2f}초 (반복)\n"
|
|
else:
|
|
# 몽타주 2개 이상: 순차 표시 + 총 합계
|
|
cycle_str = " → ".join([f"{d:.2f}초" for d in montage_durations])
|
|
md += f"- **공격 사이클**: {cycle_str} (총 {total_cycle_time:.2f}초, 반복)\n"
|
|
|
|
# 예상 공격 횟수 계산
|
|
if active_duration > 0:
|
|
cycle_count = active_duration / total_cycle_time
|
|
attack_count = cycle_count * len(attack_montages)
|
|
total_damage = attack_count * skill_damage_rate
|
|
md += f"- **예상 공격 횟수**: ~{attack_count:.1f}회\n"
|
|
md += f"- **총 피해 배율**: ~{total_damage:.2f}배 상당\n"
|
|
|
|
if dot_type:
|
|
dot_config = config.DOT_DAMAGE.get(dot_type, {})
|
|
dot_desc = dot_config.get('description', f'{dot_type} DoT')
|
|
md += f"- **특수 효과**: {dot_type} DoT ({dot_desc})\n"
|
|
|
|
md += "\n"
|
|
return md
|
|
|
|
def generate_skill_entry(skill: Dict, index: int, is_sub: bool = False, is_ultimate: bool = False) -> str:
|
|
"""개별 스킬 엔트리 생성"""
|
|
skill_id = skill.get('skillId', 'N/A')
|
|
name = skill.get('name', 'N/A')
|
|
skill_type = skill.get('skillAttackType', 'Normal')
|
|
element = skill.get('skillElementType', 'None')
|
|
damage_rate = skill.get('skillDamageRate', 0)
|
|
cooltime = skill.get('coolTime', 0)
|
|
mana = skill.get('manaCost', 0)
|
|
casting_time = skill.get('castingTime', 0)
|
|
# 설명 (descFormatted 사용 - 변수 치환 및 줄바꿈 제거됨)
|
|
desc = skill.get('descFormatted', skill.get('simpleDesc', ''))
|
|
|
|
md = ""
|
|
if index > 0:
|
|
md += f"{index}. "
|
|
|
|
md += f"**{skill_id} {name}**\n"
|
|
md += f" - **타입**: {skill_type}"
|
|
if element and element != 'None':
|
|
md += f" / **속성**: {element}"
|
|
md += "\n"
|
|
|
|
if damage_rate > 0:
|
|
md += f" - **피해 배율**: {damage_rate}\n"
|
|
|
|
# 쿨타임, 마나, 시전시간 표시
|
|
if cooltime > 0 or mana > 0 or casting_time > 0:
|
|
parts = []
|
|
if cooltime > 0:
|
|
parts.append(f"**쿨타임**: {cooltime}초")
|
|
if mana > 0:
|
|
parts.append(f"**마나**: {mana}")
|
|
if casting_time > 0:
|
|
parts.append(f"**시전시간**: {casting_time}초")
|
|
|
|
if parts:
|
|
md += f" - {' / '.join(parts)}\n"
|
|
|
|
# 특수 마커
|
|
is_dot = skill.get('isDot', False)
|
|
is_summon = skill.get('isSummon', False)
|
|
is_utility = skill.get('isUtility', False)
|
|
|
|
# 유틸리티 스킬 표시
|
|
if is_utility:
|
|
md += f" - 💡 **유틸리티 스킬** (DPS 계산 제외)\n"
|
|
|
|
if is_dot:
|
|
dot_info = config.DOT_SKILLS.get(skill_id, {})
|
|
dot_type = dot_info.get('dot_type', 'DoT')
|
|
|
|
# DoT 피해 상세 정보
|
|
if dot_type == 'Poison':
|
|
dot_detail = "대상 MaxHP의 20% (5초간)"
|
|
elif dot_type == 'Burn':
|
|
dot_detail = "대상 MaxHP의 10% (3초간)"
|
|
elif dot_type == 'Bleed':
|
|
dot_detail = "고정 20 피해 (5초간)"
|
|
else:
|
|
dot_detail = "지속 피해"
|
|
|
|
md += f" - ⚠️ **{dot_type} 상태이상 유발**: {dot_detail}\n"
|
|
md += f" - 💡 **DoT 피해는 대상 HP에 비례** (구체적 DPS는 다음 챕터 참조)\n"
|
|
|
|
if is_summon:
|
|
summon_info = config.SUMMON_SKILLS.get(skill_id, {})
|
|
summon_name = summon_info.get('summon', 'Summon')
|
|
duration = skill.get('activeDuration', 0)
|
|
md += f" - 🔮 **소환**: {summon_name} (지속 {duration}초)\n"
|
|
|
|
# 몽타주 정보 표시 (이름 + 시간 + 태그)
|
|
montage_data = skill.get('montageData', [])
|
|
if montage_data:
|
|
if len(montage_data) == 1:
|
|
# 몽타주 1개: 한 줄로 표시
|
|
montage = montage_data[0]
|
|
montage_name = montage.get('assetName', 'N/A')
|
|
tag = get_montage_tag(montage_name)
|
|
tag_display = f" {tag}" if tag else ""
|
|
md += f" - **몽타주**: {montage_name}{tag_display}\n"
|
|
else:
|
|
# 몽타주 여러 개: 리스트로 표시
|
|
md += f" - **몽타주**: \n"
|
|
for idx, montage in enumerate(montage_data, 1):
|
|
montage_name = montage.get('assetName', 'N/A')
|
|
duration = montage.get('actualDuration', 0)
|
|
tag = get_montage_tag(montage_name)
|
|
tag_display = f" {tag}" if tag else ""
|
|
md += f" {idx}. {montage_name} ({duration:.2f}초){tag_display}\n"
|
|
|
|
# 시퀀스 길이 (새로운 계산 규칙 적용)
|
|
sequence_length, is_average, included_montages = calculate_sequence_length(skill_id, montage_data)
|
|
if sequence_length > 0 or len(montage_data) > 0:
|
|
# 평균 표시 추가
|
|
avg_text = " (평균)" if is_average else ""
|
|
md += f" - **시퀀스 길이**: {sequence_length:.2f}초{avg_text}\n"
|
|
|
|
# 설명 (전체 표시)
|
|
if desc:
|
|
md += f" - **설명**: {desc}\n"
|
|
|
|
md += "\n"
|
|
|
|
return md
|
|
|
|
def main():
|
|
"""메인 실행 함수"""
|
|
print("="*80)
|
|
print("스토커 기본 데이터 문서 생성 v2")
|
|
print("="*80)
|
|
|
|
# 검증된 데이터 로드 (없으면 intermediate 사용)
|
|
validated_file = config.OUTPUT_DIR / "validated_data.json"
|
|
intermediate_file = config.OUTPUT_DIR / "intermediate_data.json"
|
|
|
|
if validated_file.exists():
|
|
data_file = validated_file
|
|
print(f"\n[ 검증된 데이터 사용 ]: {data_file}")
|
|
elif intermediate_file.exists():
|
|
data_file = intermediate_file
|
|
print(f"\n[ 중간 데이터 사용 ]: {data_file}")
|
|
print("⚠️ 검증되지 않은 데이터입니다. validate_stalker_data.py를 먼저 실행하는 것을 권장합니다.")
|
|
else:
|
|
print(f"[FAIL] 데이터 파일 없음")
|
|
print("먼저 extract_stalker_data_v2.py를 실행하세요.")
|
|
return
|
|
|
|
with open(data_file, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
print("\n[ 문서 생성 시작 ]")
|
|
|
|
# 마크다운 생성
|
|
md_content = generate_header()
|
|
md_content += generate_stalker_overview(data)
|
|
md_content += generate_ultimate_overview(data)
|
|
md_content += generate_dot_overview(data) # DoT 스킬 종합
|
|
|
|
# 개별 스토커
|
|
for stalker_id in config.STALKERS:
|
|
if stalker_id not in data:
|
|
print(f"[WARN] {stalker_id}: 데이터 없음, 건너뜀")
|
|
continue
|
|
|
|
print(f" - {stalker_id} 문서 생성 중...")
|
|
md_content += generate_stalker_detail(stalker_id, data[stalker_id])
|
|
|
|
# Footer
|
|
md_content += "---\n\n"
|
|
md_content += f"**생성 일시**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
md_content += f"**데이터 소스**: {data_file.name}\n"
|
|
md_content += f"**검증 상태**: {'검증 완료 ✅' if data_file.name == 'validated_data.json' else '미검증 ⚠️'}\n"
|
|
|
|
# 파일 저장
|
|
output_file = config.OUTPUT_DIR / "03_스토커별_기본데이터_v2.md"
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
f.write(md_content)
|
|
|
|
print(f"\n[OK] 문서 생성 완료: {output_file}")
|
|
print(f" - 총 {len(data)}명 스토커 문서 생성")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|