""" core/field_calculator.py — 타구 좌표/거리/수비 시퀀스 계산 필드 좌표 기반의 타구 처리 로직. Playwright 의존성 없음. """ from __future__ import annotations import math import re from typing import Any from core.config_loader import ( field_coordinates, hit_ball_type_map, foul_fly_coords, position_number_map, ) # ────────────────────────────────────────────── # 타구 종류 추론 # ────────────────────────────────────────────── def infer_hit_ball_type(result_text: str) -> str: """결과 텍스트에서 타구 종류 추론 '2루수 땅볼 아웃' → '땅볼' '좌익수 뒤 2루타' → '일반바운드' """ if "번트" in result_text: return "번트타구" if "몸에 맞는 타구" in result_text: return "땅볼" if "파울희생플라이" in result_text or "파울 희생플라이" in result_text: return "플라이" if "파울플라이" in result_text: return "플라이" if "라인드라이브" in result_text or "직선타" in result_text: return "라인드라이브" if "플라이" in result_text: return "플라이" if "땅볼" in result_text: return "땅볼" if "홈런" in result_text: return "홈런성타구" return "일반바운드" def get_hit_ball_type_code(hit_ball_type: str) -> str: """타구 종류 라벨 → 사이트 value 코드""" return hit_ball_type_map().get(hit_ball_type, "1") # ────────────────────────────────────────────── # 필드 존 추론 # ────────────────────────────────────────────── ORDERED_ZONES = ( "좌중간", "우중간", "좌전", "중전", "우전", "좌월", "중월", "우월", "좌익수", "중견수", "우익수", "유격수", "3루수", "2루수", "1루수", "투수", "포수", ) def infer_field_zone(result_text: str) -> str: """결과 텍스트에서 타구 방향(zone) 추론 '우익수 앞 1루타' → '우익수' """ if "몸에 맞는 타구" in result_text: return "1루수" for zone in ORDERED_ZONES: if zone in result_text: return zone return "중견수" def extract_direction_offsets(result_text: str) -> tuple[int, int]: """결과 텍스트에서 방향 오프셋 추출 '좌익수 왼쪽 뒤' → (-1, -1) """ x_delta = 0 y_delta = 0 if "왼쪽" in result_text: x_delta -= 1 if "오른쪽" in result_text: x_delta += 1 if "앞" in result_text: y_delta += 1 if "뒤" in result_text: y_delta -= 1 return x_delta, y_delta def is_infield_zone(zone: str) -> bool: """내야 존인지 확인""" return zone in {"투수", "포수", "1루수", "2루수", "3루수", "유격수"} # ────────────────────────────────────────────── # 좌표 계산 # ────────────────────────────────────────────── def get_zone_coordinates(zone: str) -> tuple[int, int]: """존 이름 → (x, y) 퍼센트 좌표""" coords = field_coordinates() coord = coords.get(zone, coords.get("중견수", [50, 24])) return tuple(coord) def get_foul_fly_coordinates(side: str) -> tuple[int, int]: """파울 플라이 좌표 ('left' 또는 'right')""" coords = foul_fly_coords() return tuple(coords.get(side, [50, 70])) def calculate_hit_ball_coordinates( result_text: str, zone: str | None = None, ) -> tuple[int, int]: """결과 텍스트로부터 타구 좌표 계산 Returns: (x, y) 퍼센트 좌표 """ if zone is None: zone = infer_field_zone(result_text) x, y = get_zone_coordinates(zone) x_delta, y_delta = extract_direction_offsets(result_text) infield = is_infield_zone(zone) step = 3 if infield else 5 x += x_delta * step y += y_delta * step # 범위 제한 x = max(0, min(100, x)) y = max(0, min(100, y)) return x, y def calculate_distance(x: int, y: int, meter_per_px: float) -> float: """좌표에서 홈까지의 거리 계산 (미터)""" home_x, home_y = 50, 93 dx = (x - home_x) * meter_per_px dy = (y - home_y) * meter_per_px return math.sqrt(dx * dx + dy * dy) # ────────────────────────────────────────────── # 수비 시퀀스 추출 # ────────────────────────────────────────────── def _position_label_map() -> dict[str, str]: """번호 → 포지션명 역매핑""" return {v: k for k, v in position_number_map().items()} def extract_defense_sequence(result_text: str) -> list[str]: """결과 텍스트에서 수비 시퀀스 추출 '2루수 땅볼 아웃 (2루수->1루수 송구아웃)' → ['2루수', '1루수'] """ pos_label = _position_label_map() # 1) '2-6', '2-5-3' 같은 숫자 패턴 num_seq_match = re.search(r"(\d+(?:-\d+)+)", result_text) if num_seq_match: nums = num_seq_match.group(1).split("-") pos_names = [pos_label[n] for n in nums if n in pos_label] if pos_names: return pos_names # 2) 괄호 안에서 포지션 추출 parenthetical_match = re.search(r"\(([^)]*)\)", result_text) if parenthetical_match: sequence = re.findall( r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)", parenthetical_match.group(1), ) if sequence: return sequence # 3) 괄호 앞 본문에서 포지션 추출 leading_text = result_text.split("(", 1)[0] sequence = re.findall( r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)", leading_text, ) if sequence: return sequence # 4) 존에서 폴백 zone = infer_field_zone(result_text) pos_num = position_number_map() if zone in pos_num: return [zone] return [] def extract_error_position(result_text: str) -> str | None: """실책 관련 텍스트에서 실책 수비자 포지션 추출""" parenthetical_match = re.search(r"\(([^)]*실책[^)]*)\)", result_text) search_texts = [parenthetical_match.group(1)] if parenthetical_match else [] search_texts.append(result_text) for text in search_texts: positions = re.findall( r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)", text, ) if positions: return positions[0] return None def infer_error_position_fallback(text: str) -> str: """실책 포지션 추론 폴백""" if "야수선택" in text: return "야수선택" if "도루" in text: return "포수" if "포구" in text: return "포수" if "송구" in text: return "투수" return "포수" def is_error_result(result_text: str) -> bool: """실책 결과인지 확인""" return "실책" in result_text def is_throwing_error(result_text: str) -> bool: """송구 실책인지 확인""" keywords = ("송구실책", "송구 실책", "악송구", "throwing error", "송구에러") return any(keyword in result_text for keyword in keywords) def is_double_play_result(result_text: str) -> bool: """병살인지 확인""" return "병살" in result_text def build_double_play_first_sequence(event: dict[str, Any]) -> list[str]: """병살 이벤트의 첫 번째 수비 시퀀스""" result_text = ((event.get("result") or {}).get("text") or "").strip() return extract_defense_sequence(result_text)