본문 바로가기

야구 잡담/야구게임

[Devlog] #2 로스터 개선하기

반응형

선수의 이름, 포지션, 나이, 2025년 스탯이 포함된 로스터 텍스트 파일을 확보했다.

json파일을 만들게 시켰는데, "vel":65,"stuff":60,"ctrl":50 같은 식의 알파벳 키를 줬다. 일반적인 개발을 할 땐 이게 유리한데, LLM의 지식에 넣었을 때 적절한진 잘 모르겠다. 이에 대해 피드백했더니, 한글로 된 키를 쓰는 게 낫다고 결론이 났다. 이름이나 나이, 포지션은 영어 키 그대로 가져가기로 했다.(name, age, position)

20-80 스케일을 기초로 등급을 평가하는데, 급간 내에서의 미세한 변동과 급간을 벗어나는 변동을 내부에서 계산하려면 숫자식이 유리하다고 한다.
이걸 하게끔 하는 코드는 별도의 py파일로 저장해서 지식에다 넣기로 했다. 우선 gpt가 짜준 게임 엔진 예시 코드는 이런 식이다.

# sim_update.py
from __future__ import annotations
import json, math, random
from dataclasses import dataclass, asdict
from typing import Dict, List, Any, Optional

# ===== 표시 등급 매핑 (20–80 스텝) =====
STEP_VALUES = [80,75,70,65,60,55,50,45,40,35,30,25,20]
NUM2GRADE = {80:"S",75:"A+",70:"A",65:"A-",60:"B+",55:"B",50:"B-",
             45:"C+",40:"C",35:"C-",30:"D+",25:"D",20:"F"}
GRADE2NUM = {v:k for k,v in NUM2GRADE.items()}

# ===== 능력치 키 =====
PITCH_KEYS = ["구속","구위","제구","변화","체력"]
BAT_KEYS   = ["컨택","파워","선구","주루","수비"]

# ===== 파라미터(원하면 숫자만 조정) =====
EPS_HYSTERESIS = 3.0  # 등급 경계 히스테리시스
MEAN_REVERT_LAMBDA = 0.05  # 앵커로 평균회귀
AGE_SIGMA = {               # 월간 노이즈 표준편차
    "youth": (1.2, 1.8),
    "prime": (0.6, 1.0),
    "veteran": (0.8, 1.4)
}
# 나이대 구간
def age_bucket(age:int)->str:
    if age <= 22: return "youth"
    if age <= 29: return "prime"
    return "veteran"

# 월간 드리프트(대략값, 필요시 수정)
def drift_by_age(age:int, phase:str, role:str, key:str)->float:
    # phase: "dev"(성장), "peak"(전성), "decline"(노화) 등 임의 태그 가능
    if age <= 22:
        base = 0.6
    elif age <= 27:
        base = 0.3
    elif age <= 31:
        base = -0.1
    else:
        base = -0.4
    # 포지션/키 가중: 구속/파워 변동성↑, 제구/선구는 완만
    if key in ("구속","파워"): base *= 1.15
    if key in ("제구","선구"): base *= 0.8
    if role in ("CL","SU"):     base *= 1.05
    if phase == "dev":          base *= 1.15
    elif phase == "decline":    base *= 1.25
    return base

def sigma_by_age_role(age:int, role:str)->float:
    lo,hi = AGE_SIGMA[age_bucket(age)]
    x = 0.1 if role in ("CL","SU") else 0.0
    return (lo+hi)/2 + x

def clamp_20_80(x:float)->float:
    return max(20.0, min(80.0, x))

def nearest_step(x:float)->int:
    return min(STEP_VALUES, key=lambda s: abs(s-x))

def to_grade_display(x:float, prev_grade:Optional[str]=None)->str:
    target_step = nearest_step(x)
    grade = NUM2GRADE[target_step]
    if prev_grade:
        prev_step = GRADE2NUM[prev_grade]
        if abs(x - prev_step) < EPS_HYSTERESIS:
            return prev_grade
    return grade

# ===== 이벤트 충격 모델 (간단 훅; 필요시 확장) =====
def shock_from_events(events:List[Dict[str,Any]], key:str)->float:
    total = 0.0
    for e in events or []:
        t = e.get("type")
        sev = e.get("sev", 1.0)
        if t == "injury":
            # 키별 충격 차등 (예: 어깨 -> 구속/구위, 손목 -> 컨택/파워 등)
            if key in e.get("keys_affect", []):
                total += - (1.5 + 2.0*sev)
        elif t == "fatigue":
            if key in ("체력","구위","제구"): total += - (0.5 + 0.5*sev)
        elif t == "good_coach":
            if key in e.get("keys_affect", []): total +=  (0.6 + 0.6*sev)
        elif t == "training":
            if key in e.get("keys_affect", []): total +=  (0.3 + 0.4*sev)
    return total

# ===== 평균회귀 앵커 (프리시즌 스카우팅값 또는 EMA) =====
def anchor_value(baseline:float, ema:Optional[float])->float:
    return ema if ema is not None else baseline

@dataclass
class Player:
    id: str
    name: str
    age: int
    role_or_pos: str   # 투수: 보직(SP/RP/SU/CL), 타자: 포지션(SS/RF 등)
    phase: str         # "dev"/"peak"/"decline" 등 태그
    baseline: Dict[str,float]   # 스카우팅 앵커(20~80)
    ema: Dict[str,Optional[float]]  # 장기 EMA(없으면 None)
    skills: Dict[str,float]     # 현재 능력치(연속값 20~80)
    prev_grade: Dict[str,Optional[str]]  # 직전 표시 등급(히스테리시스용)

    def update_month(self, events:List[Dict[str,Any]], is_pitcher:bool, rng:random.Random):
        keys = PITCH_KEYS if is_pitcher else BAT_KEYS
        for k in keys:
            r = self.skills[k]
            r += drift_by_age(self.age, self.phase, self.role_or_pos, k)
            r += shock_from_events(events, k)
            r += rng.gauss(0.0, sigma_by_age_role(self.age, self.role_or_pos))
            # 평균회귀
            anch = anchor_value(self.baseline.get(k, r), self.ema.get(k))
            r += MEAN_REVERT_LAMBDA * (anch - r)
            self.skills[k] = clamp_20_80(r)
        # 나이 증가 규칙(월 단위: 12개월당 +1) 등은 시즌말에 반영 권장

    def view_grades(self, is_pitcher:bool)->Dict[str,str]:
        keys = PITCH_KEYS if is_pitcher else BAT_KEYS
        out = {}
        for k in keys:
            g = to_grade_display(self.skills[k], self.prev_grade.get(k))
            out[k] = g
            self.prev_grade[k] = g
        return out

# ===== 팀 단위 유틸 =====
def load_team_engine_json(path:str)->Dict[str,Any]:
    with open(path,"r",encoding="utf-8") as f:
        return json.load(f)

def save_team_engine_json(data:Dict[str,Any], path:str):
    with open(path,"w",encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

def players_from_json(team:Dict[str,Any])->Dict[str,List[Player]]:
    def to_player(d:Dict[str,Any], is_pitcher:bool)->Player:
        keys = PITCH_KEYS if is_pitcher else BAT_KEYS
        skills = {k: float(d[k]) for k in keys}
        baseline = {k: float(d.get("baseline",{}).get(k, skills[k])) for k in keys}
        ema = {k: d.get("ema",{}).get(k) for k in keys}
        prev_grade = {k: d.get("prev_grade",{}).get(k) for k in keys}
        return Player(
            id=d["id"], name=d["name"], age=int(d.get("age",26)),
            role_or_pos=d["보직" if is_pitcher else "포지션"],
            phase=d.get("phase","peak"),
            baseline=baseline, ema=ema, skills=skills, prev_grade=prev_grade
        )
    P = [to_player(p, True)  for p in team.get("pitchers",[])]
    B = [to_player(b, False) for b in team.get("batters",[])]
    return {"P":P, "B":B}

def serialize_players(P:List[Player], B:List[Player])->Dict[str,Any]:
    def ser(pl:Player, is_pitcher:bool)->Dict[str,Any]:
        d = {
            "id":pl.id, "name":pl.name, "age":pl.age,
            ("보직" if is_pitcher else "포지션"): pl.role_or_pos,
            "phase":pl.phase,
            "baseline":pl.baseline,
            "ema":pl.ema,
            "prev_grade":pl.prev_grade
        }
        keys = PITCH_KEYS if is_pitcher else BAT_KEYS
        for k in keys: d[k] = round(pl.skills[k], 1)  # 저장은 소수1자리
        return d
    return {
        "pitchers":[ser(p,True) for p in P],
        "batters":[ser(b,False) for b in B]
    }

# ===== 예시 실행 함수 =====
def run_one_month(team_path:str, seed:int=7, events_by_id:Dict[str,List[Dict[str,Any]]]|None=None, out_path:str|None=None, render_view:bool=False):
    rng = random.Random(seed)
    team = load_team_engine_json(team_path)
    roster = players_from_json(team)
    events_by_id = events_by_id or {}

    for p in roster["P"]:
        p.update_month(events_by_id.get(p.id, []), True, rng)
    for b in roster["B"]:
        b.update_month(events_by_id.get(b.id, []), False, rng)

    # 저장 (엔진 숫자 유지)
    updated = serialize_players(roster["P"], roster["B"])
    team.update(updated)
    if out_path is None: out_path = team_path
    save_team_engine_json(team, out_path)

    if render_view:
        # 표시 레이어(등급) 샘플
        view = {
            "팀": team.get("team",""),
            "투수": [{ "이름":p.name, **p.view_grades(True)} for p in roster["P"]],
            "타자": [{ "이름":b.name, **b.view_grades(False)} for b in roster["B"]],
        }
        print(json.dumps(view, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    # 사용 예:
    # run_one_month("2026_KIA.engine.json", seed=42, events_by_id={"KIA-P-002":[{"type":"injury","sev":1.5,"keys_affect":["구위","체력"]}]}, render_view=True)
    pass

코드 인터프리터를 강제하는 프롬프팅으로 사용이 가능할 것 같기도 하다. 이후 게임 엔진에 해당하는 부분은 추후의 devlog에서 다루도록 한다.

반응형

'야구 잡담 > 야구게임' 카테고리의 다른 글

[Devlog] #1 로스터 구성하기  (0) 2025.11.28
[Devlog] #0 야구 단장 게임 개발 배경  (0) 2025.11.28