268 lines
12 KiB
Python
268 lines
12 KiB
Python
import json
|
|
import re
|
|
import random
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import desc, func
|
|
from langchain_core.prompts import ChatPromptTemplate
|
|
from langchain_core.output_parsers import StrOutputParser
|
|
from backend.ai.llm import hugging_llm
|
|
from datetime import datetime
|
|
from backend.repository.db_init import SessionLocal
|
|
from backend.repository.models import AncestorDeath, DialogueCache
|
|
from backend.schemas.game_schema import AncestorDeathCreate, SkillCreateResponse
|
|
|
|
# 랜덤 이름 리스트 (자르반, 가렌 등 판타지 풍 이름)
|
|
RANDOM_NAMES = [
|
|
"자르반", "가렌", "럭스", "다리우스", "야스오", "티모", "아리", "이즈리얼",
|
|
"조이", "제이스", "카이사", "제드", "탈론", "리븐", "애쉬", "트린다미어",
|
|
"소나", "룰루", "유미", "레오나", "다이애나", "세주아니", "우디르", "신 짜오"
|
|
]
|
|
|
|
# 1. 다음 도전 세대 번호 계산
|
|
def get_next_generation(db: Session) -> int:
|
|
max_gen = db.query(func.max(AncestorDeath.generation)).scalar()
|
|
return (max_gen + 1) if max_gen is not None else 1
|
|
|
|
# 2. 선조 사망 기록 등록 (클라이언트에서 생성해 보낸 데이터 그대로 저장)
|
|
def create_death_history(db: Session, death_data: AncestorDeathCreate):
|
|
db_death = AncestorDeath(
|
|
name=death_data.name,
|
|
generation=death_data.generation,
|
|
cause_of_death=death_data.cause_of_death,
|
|
floor=death_data.floor
|
|
)
|
|
db.add(db_death)
|
|
db.commit()
|
|
db.refresh(db_death)
|
|
return db_death
|
|
|
|
# 3. 문지기 NPC 조언 대사 사전 생성 (비동기 백그라운드 구동)
|
|
def pregenerate_npc_dialogue(next_generation: int):
|
|
# 비동기로 돌기 때문에 직접 세션을 생성해서 처리합니다.
|
|
db = SessionLocal()
|
|
try:
|
|
print(f"[Pregen] {next_generation}대손용 문지기 대사 백그라운드 생성 시작...")
|
|
|
|
# 1. AI 조언 대사 생성
|
|
dialogue_text = generate_npc_dialogue(db=db, current_generation=next_generation)
|
|
|
|
# 2. 캐시 DB에 저장 (이미 존재한다면 Overwrite 하여 덮어씁니다.)
|
|
cached = db.query(DialogueCache).filter(DialogueCache.generation == next_generation).first()
|
|
if cached:
|
|
cached.dialogue = dialogue_text
|
|
cached.created_at = datetime.now()
|
|
else:
|
|
cached = DialogueCache(
|
|
generation=next_generation,
|
|
dialogue=dialogue_text
|
|
)
|
|
db.add(cached)
|
|
|
|
db.commit()
|
|
print(f"[Pregen] {next_generation}대손용 대사 캐싱 완료: {dialogue_text}")
|
|
except Exception as e:
|
|
db.rollback()
|
|
print(f"[Pregen ERROR] 대사 백그라운드 생성 실패: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
# 2. 문지기 NPC 조언 대사 생성
|
|
def generate_npc_dialogue(db: Session, current_generation: int) -> str:
|
|
# 현재 세대 이전(부모 세대들)의 사망 기록 중 최근 3개를 가져옴
|
|
ancestors = (
|
|
db.query(AncestorDeath)
|
|
.filter(AncestorDeath.generation < current_generation)
|
|
.order_by(desc(AncestorDeath.generation), desc(AncestorDeath.created_at))
|
|
.limit(3)
|
|
.all()
|
|
)
|
|
|
|
if not ancestors:
|
|
# 선조 기록이 없는 최초 세대인 경우
|
|
ancestor_history = "이전 선조들의 기록이 존재하지 않는 최초의 도전자입니다."
|
|
else:
|
|
history_lines = []
|
|
for a in ancestors:
|
|
# 부모, 조부모 등의 호칭 정리
|
|
diff = current_generation - a.generation
|
|
relation = "선조"
|
|
if diff == 1:
|
|
relation = "아버지(혹은 어머니)"
|
|
elif diff == 2:
|
|
relation = "할아버지(혹은 할머니)"
|
|
elif diff == 3:
|
|
relation = "증조할아버지(혹은 증조할머니)"
|
|
|
|
history_lines.append(
|
|
f"- {relation} '{a.name}' (세대: {a.generation}대): 던전 {a.floor}층에서 '{a.cause_of_death}'에 의해 사망함."
|
|
)
|
|
ancestor_history = "\n".join(history_lines)
|
|
|
|
# 늙은 문지기 NPC 프롬프트 정의
|
|
npc_prompt = """당신은 무한 반복 로그라이크 던전의 노련하고 퉁명스러운 늙은 문지기 NPC '발두르'입니다.
|
|
새로 던전에 들어서려는 플레이어(자손)에게 그의 선조(부모, 조부모 등)가 어떻게 죽었는지를 언급하며, 안타까워하면서도 겉으로는 퉁명스럽게 조언하는 츤데레 말투의 한글 대사를 생성하세요.
|
|
|
|
[성격 및 말투 가이드라인]
|
|
- 반말을 사용하되, 지나치게 거칠거나 비하하는 어조의 상스러운 단어("뒈졌다", "아비/할아비", "멍청한 놈" 등)는 삼가고 묵직하고 연륜 있는 톤을 유지하세요.
|
|
- 선조의 죽음은 "최후를 맞이했다", "목숨을 잃었다", "스러졌다" 같은 절제된 어휘를 사용해 표현하세요.
|
|
- 번역투(~를 주의해, 너의 조상들 등)를 피하고, 자연스러운 한국 판타지 소설 속 숙련된 경비병/현자 말투를 구사해야 합니다.
|
|
- 1~2문장의 짧고 굵은 대사로만 출력하세요. 대사 이외의 서술이나 설명은 일절 배제하세요.
|
|
|
|
[자연스러운 말투 예시 (Few-Shot)]
|
|
- "네 아버지는 1층의 가시 함정을 피하지 못하고 허망하게 최후를 맞이했지. 살아서 돌아오고 싶거든 바닥을 잘 살피며 걷거라."
|
|
- "네 조부 역시 2층에서 붉은 슬라임의 불길에 그만 스러졌다. 너만큼은 그 전철을 밟지 말고 안전하게 올라가거라."
|
|
|
|
[이전 선조들의 사망 기록]
|
|
{ancestor_history}
|
|
|
|
현재 도전자는 {current_generation}대 자손입니다.
|
|
위 선조들의 구체적인 사망 원인을 직접 언급하며, 이번 도전에서 같은 실수를 반복하지 않도록 묵직하게 호통치거나 조언하는 대사를 한글로 출력하세요.
|
|
"""
|
|
|
|
prompt_template = ChatPromptTemplate.from_template(npc_prompt)
|
|
chain = prompt_template | hugging_llm | StrOutputParser()
|
|
|
|
result = chain.invoke({
|
|
"ancestor_history": ancestor_history,
|
|
"current_generation": current_generation
|
|
})
|
|
|
|
return result.strip()
|
|
|
|
# LLM 출력에서 JSON 파싱하는 헬퍼 함수
|
|
def _parse_json_from_llm(text: str) -> dict:
|
|
# ```json ... ``` 형식 추출 시도
|
|
match = re.search(r"```json\s*({.*?})\s*```", text, re.DOTALL)
|
|
if match:
|
|
try:
|
|
return json.loads(match.group(1))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# 일반 { ... } 중괄호 내용 추출 시도
|
|
match = re.search(r"({.*})", text, re.DOTALL)
|
|
if match:
|
|
try:
|
|
return json.loads(match.group(1))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# 텍스트 그대로 파싱 시도
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
raise ValueError(f"LLM 응답에서 JSON을 파싱하지 못했습니다: {text}")
|
|
|
|
# 3. AI 기반 스킬 생성 및 밸런싱
|
|
def generate_ai_skill(prompt: str, player_level: int) -> SkillCreateResponse:
|
|
# 1. 플레이어 레벨에 따른 스탯 버젯(Budget) 결정
|
|
# 예: 레벨 1 -> 8, 레벨 5 -> 16, 레벨 10 -> 26 등
|
|
budget = 6.0 + player_level * 2.0
|
|
max_damage = budget - 2.0 # 데미지는 최대 버젯에서 다른 능력치 최소값을 뺀 값
|
|
|
|
# 스킬 생성 프롬프트 정의
|
|
skill_prompt = """당신은 플레이어의 입력을 바탕으로 게임 속성과 스탯이 잘 분배된 전투 스킬을 설계하는 AI 게임 기획자입니다.
|
|
플레이어의 한글 텍스트 스킬 설명(또는 구호)을 바탕으로, 요구된 JSON 형식에 따라 스킬 데이터를 생성하십시오.
|
|
|
|
[플레이어 요청]
|
|
"{prompt}"
|
|
|
|
[게임 밸런스 제약 조건]
|
|
- 이 스킬의 세 가지 수치 (scale, damage, speed)의 합은 정확히 {budget} 이하이어야 합니다.
|
|
- 각 스탯의 유효 범위:
|
|
- scale (스킬 크기/범위): 최소 0.5, 최대 10.0 (광역 스킬일수록 높게 배분)
|
|
- damage (스킬 피해량): 최소 1.0, 최대 {max_damage} (단일 강공격일수록 높게 배분)
|
|
- speed (스킬 속도/투사체 속도): 최소 0.5, 최대 10.0 (신속한 공격일수록 높게 배분)
|
|
- 플레이어의 요청 분위기에 맞게 이 세 가지 수치를 적절히 배분하십시오.
|
|
- visual_effect 속성에는 다음 중 가장 적절한 하나를 선택해 넣으십시오:
|
|
["fire", "ice", "electric", "physical", "poison", "dark", "light"]
|
|
- skill_type 속성에는 다음 중 가장 적절한 하나를 선택해 넣으십시오:
|
|
["projectile", "splash", "buff", "aura", "strike"]
|
|
|
|
[반드시 준수할 출력 JSON 형식]
|
|
설명 없이 오직 아래 형식의 JSON 데이터 하나만 출력하세요. 다른 텍스트는 일체 허용되지 않습니다.
|
|
{{
|
|
"skill_name": "스킬 명칭",
|
|
"scale": 3.0,
|
|
"damage": 5.0,
|
|
"speed": 2.0,
|
|
"visual_effect": "선택된 이펙트",
|
|
"skill_type": "선택된 스킬 타입",
|
|
"description": "스킬에 대한 1줄 요약 설명"
|
|
}}
|
|
"""
|
|
|
|
prompt_template = ChatPromptTemplate.from_template(skill_prompt)
|
|
chain = prompt_template | hugging_llm | StrOutputParser()
|
|
|
|
raw_response = chain.invoke({
|
|
"prompt": prompt,
|
|
"budget": budget,
|
|
"max_damage": max_damage
|
|
})
|
|
|
|
# 2. JSON 파싱
|
|
try:
|
|
skill_data = _parse_json_from_llm(raw_response)
|
|
except Exception as e:
|
|
# 파싱 실패 시 기본 안전 장치(Fallback) 제공
|
|
skill_data = {
|
|
"skill_name": f"임시 {prompt.split()[0] if prompt.split() else '스킬'}",
|
|
"scale": 1.0,
|
|
"damage": float(player_level * 2),
|
|
"speed": 1.0,
|
|
"visual_effect": "physical",
|
|
"skill_type": "strike",
|
|
"description": "마력 흐름이 불안정하여 생성된 임시 스킬입니다."
|
|
}
|
|
|
|
# 3. 백엔드 자체 밸런싱 강제 규칙 (LLM 오작동 방지용 검증/보정 파이프라인)
|
|
# 3-1. 수치 유효 범위 체크 및 캐스팅
|
|
try:
|
|
scale = max(0.5, min(10.0, float(skill_data.get("scale", 1.0))))
|
|
damage = max(1.0, min(max_damage, float(skill_data.get("damage", 1.0))))
|
|
speed = max(0.5, min(10.0, float(skill_data.get("speed", 1.0))))
|
|
except (ValueError, TypeError):
|
|
scale, damage, speed = 1.0, float(player_level * 2), 1.0
|
|
|
|
# 3-2. 총합 버젯 검증 및 강제 보정 (Normalize)
|
|
total_stat = scale + damage + speed
|
|
if total_stat > budget:
|
|
# 초과할 경우 비율에 따라 스탯을 보정(정규화)하여 버젯 이하로 조절
|
|
ratio = budget / total_stat
|
|
scale = round(scale * ratio, 1)
|
|
damage = round(damage * ratio, 1)
|
|
speed = round(speed * ratio, 1)
|
|
|
|
# 보정 후 정밀도 오차 등으로 아주 미세하게 budget을 넘을 경우 소수점 조정
|
|
total_stat = scale + damage + speed
|
|
if total_stat > budget:
|
|
damage = round(damage - (total_stat - budget), 1)
|
|
else:
|
|
# 소수점 1자리로 반올림 정리
|
|
scale = round(scale, 1)
|
|
damage = round(damage, 1)
|
|
speed = round(speed, 1)
|
|
|
|
# 3-3. 문자열 검증 및 디폴트
|
|
valid_effects = ["fire", "ice", "electric", "physical", "poison", "dark", "light"]
|
|
valid_types = ["projectile", "splash", "buff", "aura", "strike"]
|
|
|
|
visual_effect = skill_data.get("visual_effect", "physical").lower()
|
|
if visual_effect not in valid_effects:
|
|
visual_effect = "physical"
|
|
|
|
skill_type = skill_data.get("skill_type", "strike").lower()
|
|
if skill_type not in valid_types:
|
|
skill_type = "strike"
|
|
|
|
return SkillCreateResponse(
|
|
skill_name=skill_data.get("skill_name", "이름 없는 기술"),
|
|
scale=scale,
|
|
damage=damage,
|
|
speed=speed,
|
|
visual_effect=visual_effect,
|
|
skill_type=skill_type,
|
|
description=skill_data.get("description", "신비로운 에너지가 담긴 기술입니다.")
|
|
)
|