Files
Project-AI-Chronicle/ai-server/backend/services/game_service.py
T

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", "신비로운 에너지가 담긴 기술입니다.")
)