"""채점 로직 — docs/SCORING.md (SSOT) 그대로 구현. 배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분(한 팀 득점 일치) 1 · 빗나감 0. 단조 증가(정확>근접>승패>부분>빗나감). 야구(KBO/MLB)는 득점 범위가 넓어 같은 등급 체계에 판정만 완화한다: 근접 = 승패 + 득실차 오차 ±1, 부분 = 한 팀 득점 오차 ±1. 배점·배제 대상은 backend/data/scoring.json 에서 기동 시 로드(없으면 아래 기본값). """ from __future__ import annotations import json import logging from pathlib import Path log = logging.getLogger("triplepick.scoring") # 기본 배점 — backend/data/scoring.json 로드 시 덮어씀 (docs/SCORING.md §2) SCORE = {"EXACT": 5, "CLOSE": 3, "OUTCOME": 2, "PARTIAL": 1, "MISS": 0} def outcome_of(score_a: int, score_b: int) -> str: if score_a > score_b: return "TEAM_A_WIN" if score_a < score_b: return "TEAM_B_WIN" return "DRAW" # 야구 판정 허용 오차 — 근접(득실차)·부분(한 팀 득점) 공통 ±1 BASEBALL_TOLERANCE = 1 def _grade_of( pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False ) -> str: """등급 판정 (단일 SSOT) — baseball 이면 근접/부분 오차 ±1 허용.""" tol = BASEBALL_TOLERANCE if baseball else 0 if pick_a == result_a and pick_b == result_b: return "EXACT" if outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b): if abs((pick_a - pick_b) - (result_a - result_b)) <= tol: return "CLOSE" return "OUTCOME" if abs(pick_a - result_a) <= tol or abs(pick_b - result_b) <= tol: return "PARTIAL" return "MISS" def score_prediction( pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False ) -> int: """내 예측 vs 실제 결과 → 적중 포인트 (결정론적).""" return SCORE[_grade_of(pick_a, pick_b, result_a, result_b, baseball)] # ── 자격 · 배제 (docs/SCORING.md §4, P1 구현) ────────────────── STAFF_EMAILS: set[str] = set() # 운영진/임직원 블록리스트 STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"] def is_excluded(email: str) -> bool: e = email.strip().lower() return e in STAFF_EMAILS or any(e.endswith("@" + d) for d in STAFF_DOMAINS) # ── 외부 설정 로드 (backend/data/scoring.json, key-value) ────── DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "scoring.json" # 등급 → scoring.json 의 key _JSON_KEY = { "EXACT": "score_exact", "CLOSE": "score_close", "OUTCOME": "score_outcome", "PARTIAL": "score_partial", "MISS": "score_miss", } def grade_prediction( pick_a: int, pick_b: int, result_a: int, result_b: int, path: Path | None = None, baseball: bool = False, ) -> tuple[str, int]: """유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value). score_prediction 과 같은 판정 기준(_grade_of 공유). 예: 승패+득실차 일치 → ("score_close", 3). scoring.json 이 없거나 키가 빠지면 기본 SCORE 값으로 대체. """ p = path or DATA_FILE try: data = json.loads(p.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e) data = {} grade = _grade_of(pick_a, pick_b, result_a, result_b, baseball) key = _JSON_KEY[grade] value = data[key] if isinstance(data.get(key), int) else SCORE[grade] return key, value _SCORE_KEYS = { "score_exact": "EXACT", "score_close": "CLOSE", "score_outcome": "OUTCOME", "score_partial": "PARTIAL", "score_miss": "MISS", } def load_scoring_data(path: Path | None = None) -> dict: """data/scoring.json 을 읽어 배점(SCORE)·배제 대상을 갱신. 기동 시 1회 호출. SCORE/STAFF_EMAILS/STAFF_DOMAINS 는 타 모듈이 import 한 객체라 재할당 대신 in-place 갱신. 파일이 없거나 깨져도 기본값으로 동작(부팅 실패 방지). """ p = path or DATA_FILE try: data = json.loads(p.read_text(encoding="utf-8")) except FileNotFoundError: log.warning("scoring data 없음 (%s) — 기본 배점 사용", p) return {} except (OSError, json.JSONDecodeError) as e: log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e) return {} for key, score_key in _SCORE_KEYS.items(): if isinstance(data.get(key), int): SCORE[score_key] = data[key] if isinstance(data.get("excluded_emails"), list): STAFF_EMAILS.clear() STAFF_EMAILS.update(e.strip().lower() for e in data["excluded_emails"]) if isinstance(data.get("excluded_domains"), list): STAFF_DOMAINS[:] = [ d.strip().lower().lstrip("@") for d in data["excluded_domains"] ] log.info( "scoring data 로드 (%s): 배점 %s · 배제 이메일 %d · 도메인 %s", p, SCORE, len(STAFF_EMAILS), STAFF_DOMAINS, ) return data