o2o-triple-pick/backend/app/scoring.py
jwkim 4e1ebc8989 feat: KBO/MLB 운영 안정화 — 순위 탭·라이브 개선·취소/더블헤더 지원·야구 점수제
AI 예측
- 생성 시점을 킥오프 22시간 전으로 변경(선발투수 예고 이후), 잡 매시간 실행
- 경기 시작 후 생성 금지(사후 예측 방지), 경기 단위 커밋으로 중단 시 진행분 보존

일정·라이브 UI
- 야구 일정에 순위 탭 추가 (KBO 단일 순위표 · MLB 6개 디비전, /api/standings 신설)
- 경기중 카드에 현재 이닝·실시간 스코어 표시 (30s 폴링, 서버 15s 캐시)
- MLB 선발 누락 수정: statsapi 날짜 파라미터 미국 날짜 기준 보정(-1일)
- 라이브 대기타석 스크롤바 다크 테마 처리

운영 견고성
- 우천취소: 경기 삭제 → cancelled 상태 보존 (투표·예측 기록 유지, 정산/투표/AI 제외)
- 더블헤더: 시작시각순 seq로 1·2차전 분리 등록 (match_id _2, 정산·라이브·프리뷰 매핑)
- 축구 데이터 수집을 wc 리그로 한정 (야구 팀을 축구 API에 검색하던 쿼터 낭비 수정)

점수제
- 야구 판정 완화: 근접=득실차 ±1, 부분=한 팀 득점 ±1 (등급·배점은 리그 공통 유지)
- 채점 판정을 _grade_of() 단일 함수로 통합, 종료된 야구 경기 재채점
- 배점 안내 모달 야구 문구 분리, docs/SCORING.md 갱신

기타
- 야구 전용 댓글 닉네임 풀 (종목별 세션 캐시 분리)
- 리더보드 리그별 조회 연동 (?league=)
2026-07-22 11:01:10 +09:00

141 lines
5.1 KiB
Python

"""채점 로직 — 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