""" automation/batter_input.py — 타석 결과 입력 타석의 결과(타구 좌표, 타격 결과, 타자 진루)를 입력합니다. """ from __future__ import annotations import math import hashlib from typing import Any from playwright.sync_api import Page from core.pitch_classifier import infer_batter_result_label, is_ball_in_play_event from core.field_calculator import ( infer_hit_ball_type, infer_field_zone, get_hit_ball_type_code, get_zone_coordinates, get_foul_fly_coordinates, extract_direction_offsets, is_infield_zone, ) from automation.page_helpers import ( click_radio_by_label, get_checked_event_name, get_last_visible_enabled_locator, ) from automation.defense_popup import ( fill_error_defense_popup, click_defense_sequence_in_popup, fill_runner_out_defense, ) def _deterministic_offset(seed_text: str, radius: int) -> tuple[int, int]: """텍스트 기반 결정적 난수 오프셋 생성""" digest = hashlib.md5(seed_text.encode("utf-8")).digest() x_offset = (digest[0] % (radius * 2 + 1)) - radius y_offset = (digest[1] % (radius * 2 + 1)) - radius return x_offset, y_offset def _apply_hit_ball_variation( result_text: str, result_type: str, zone: str, x: int, y: int, ) -> tuple[int, int]: """타구 텍스트에 따른 좌표 변화율(오프셋) 적용""" dir_x, dir_y = extract_direction_offsets(result_text) if "파울플라이" in result_text or "파울희생플라이" in result_text or "파울 희생플라이" in result_text: is_left = any(token in result_text for token in ("좌", "3루", "유격")) foul_x, foul_y = get_foul_fly_coordinates("left" if is_left else "right") x_offset, y_offset = _deterministic_offset(result_text, 2) return ( max(0, min(100, foul_x + x_offset)), max(50, min(100, foul_y + y_offset)), ) if result_type == "home_run": x_offset, y_offset = _deterministic_offset(result_text, 2) return ( max(15, min(85, x + x_offset)), max(12, min(22, y + y_offset)), ) if is_infield_zone(zone): base_shift = 3 random_radius = 2 if result_type == "out" else 3 else: base_shift = 12 random_radius = 5 if result_type == "out" else 7 x_offset, y_offset = _deterministic_offset(result_text, random_radius) return ( max(10, min(90, x + dir_x * base_shift + x_offset)), max(18, min(96, y + dir_y * base_shift + y_offset)), ) def build_hit_ball_payload(page: Page, result_text: str) -> dict[str, str]: """타구 좌표 및 거리 페이로드 생성""" zone = infer_field_zone(result_text) x, y = get_zone_coordinates(zone) meter_per_px_text = page.locator("#dat_meterPerPx").input_value() or "0" try: meter_per_px = float(meter_per_px_text) except ValueError: meter_per_px = 0.0 result_type = "home_run" if "홈런" in result_text else ("out" if "아웃" in result_text or "희생" in result_text else "safe") hit_ball_type_label = infer_hit_ball_type(result_text) x, y = _apply_hit_ball_variation(result_text, result_type, zone, x, y) px_x = math.floor(650 * x / 100) px_y = math.floor(621 * y / 100) distance = 0 if meter_per_px: distance = math.floor(math.sqrt((50 - x) ** 2 + (95 - y) ** 2) * math.hypot(6.5, 6.21) * meter_per_px / 100) return { "type": get_hit_ball_type_code(hit_ball_type_label), "label": hit_ball_type_label, "x": str(px_x), "y": str(px_y), "xy": f"{x},{y}", "distance": str(distance), } def set_hit_ball_and_defense(page: Page, event: dict[str, Any]) -> bool: """구장 팝업이 열렸을 때 타구 좌표 지정 및 수비 결과 입력""" if not is_ball_in_play_event(event): return False result_text = ((event.get("result") or {}).get("text") or "").strip() if not result_text: return False # 팝업 가시성 대기 try: page.wait_for_selector("#div_stadium_image", state="visible", timeout=2000) except Exception: return False # 타구 좌표 계산 및 입력 payload = build_hit_ball_payload(page, result_text) page.evaluate( """(payload) => { const mapImg = document.getElementById('mapImg'); if (!mapImg) return; document.getElementById('dat_evt_hit_type').value = payload.type; const dropDown = document.querySelector("#div_hit_type button.dropdown-toggle"); if (dropDown) { dropDown.innerHTML = payload.label + ' '; } document.getElementById('dat_hit_x').value = payload.x; document.getElementById('dat_hit_y').value = payload.y; document.getElementById('dat_hit_xy').value = payload.xy; document.getElementById('dat_hit_distance').value = payload.distance; document.getElementById('distance').value = payload.distance; const mark = document.getElementById('map_mark'); if (mark) { mark.style.display = 'block'; mark.style.left = payload.x + 'px'; mark.style.top = payload.y + 'px'; } }""", payload, ) page.wait_for_timeout(300) # 타구 결과에 따른 수비 팝업/입력 처리 result_type = (event.get("result") or {}).get("type") or "" if result_type in {"single_error_advance", "double_error_advance", "triple_error_advance"}: fill_error_defense_popup(page, result_text) elif result_type in {"reach_on_error"}: fill_error_defense_popup(page, result_text) elif result_type in {"single_runner_out", "double_runner_out", "triple_runner_out"}: fill_runner_out_defense(page, result_text) elif "병살" in result_text: from core.field_calculator import build_double_play_first_sequence seq = build_double_play_first_sequence(event) if seq: click_defense_sequence_in_popup(page, seq) btn = get_last_visible_locator(page, "#btnNext") if btn: btn.click() page.wait_for_timeout(100) elif "실책" in result_text: fill_error_defense_popup(page, result_text) elif result_type in {"out", "double_play"} and "삼진" not in result_text: from core.field_calculator import extract_defense_sequence seq = extract_defense_sequence(result_text) if seq: click_defense_sequence_in_popup(page, seq, complete_button_selector="#btnAdd") # 홈런일 경우 입력 완료 버튼 직접 클릭 if result_type == "home_run": try: page.locator("#btnInputComplete").click(timeout=1000) except Exception: pass return True def set_batter_result_type(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None: """타격 결과 종류(1루타, 수비실책 등)만 세팅""" if not result: return label = infer_batter_result_label(result, event) if not label: return # 강제 세팅 (병살 등) if label == "병살-아웃": forced = page.evaluate( """(eventName) => { const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")]; for (const node of nodes) { const name = (node.getAttribute('eventName') || '').trim(); if (name === eventName) { node.disabled = false; node.checked = true; node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); node.dispatchEvent(new Event('change', { bubbles: true })); return true; } } return false; }""", label, ) if forced: page.wait_for_timeout(120) if get_checked_event_name(page, "evt_batter") == label: return # JS 강제 이벤트 발생 marker = page.evaluate( """(eventName) => { const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")]; for (let i = nodes.length - 1; i >= 0; i -= 1) { const node = nodes[i]; const rect = node.getBoundingClientRect(); const style = window.getComputedStyle(node); if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) { continue; } if ((node.getAttribute('eventName') || '') === eventName) { const marker = `codex-batter-${Math.random().toString(36).slice(2)}`; node.setAttribute('data-codex-marker', marker); return marker; } } return null; }""", label, ) if marker: candidate = page.locator(f"[data-codex-marker='{marker}']") for _ in range(3): try: candidate.click(force=True) except Exception: candidate.evaluate( """(node) => { node.disabled = false; node.checked = true; node.dispatchEvent(new Event('click', { bubbles: true })); node.dispatchEvent(new Event('change', { bubbles: true })); }""" ) if get_checked_event_name(page, "evt_batter") == label: return page.wait_for_timeout(100) # 폴백 for _ in range(3): click_radio_by_label(page, "evt_batter", label) if get_checked_event_name(page, "evt_batter") == label: break page.wait_for_timeout(100) def set_batter_advancement(page: Page, result: dict[str, Any] | None) -> None: """타자의 최종 루(1루, 2루 등)와 주루가산 세팅""" if not result: return to_base = result.get("toBase") # 기본 진루 if to_base is None: r_type = result.get("type") if r_type in {"single", "bunt_hit", "reach_on_error", "reach_on_fielder_choice", "reach_on_grounder", "hit_by_pitch", "walk", "intentional_walk"}: to_base = 1 elif r_type == "double": to_base = 2 elif r_type == "triple": to_base = 3 elif r_type == "home_run": to_base = 4 if to_base is not None: try: selector = f"input[name='dat_evt_batter_advance'][value='{to_base}']" locator = get_last_visible_enabled_locator(page, selector) if locator is not None: locator.check(force=True) else: fallback = page.locator(selector) if fallback.count() > 0: fallback.first.check(force=True) except Exception: pass # 주루가산 (Extra Advance) try: extra_advance = result.get("extra_advance") if extra_advance is not None and extra_advance > 0: locator = get_last_visible_enabled_locator(page, "#batterRunningAdd") if locator is not None: locator.select_option(value=str(extra_advance)) else: fallback = page.locator("#batterRunningAdd") if fallback.count() > 0: fallback.first.select_option(value=str(extra_advance)) except Exception: pass def set_batter_result(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None: """타격 결과와 진루/가산 세팅""" set_batter_result_type(page, result, event) set_batter_advancement(page, result)