o2o-triple-pick/docs/SCORING.md

8.6 KiB

TriplePick — 참여자 채점 · 심사 설계 (SSOT)

최종 갱신: 2026-06-09 KST 근거: 001. Meeting Minute/WorldCup_triplepick landing_2026.06.09.txt (05:48 승패/스코어 가점, 30:36~31:37 포인트 총점·주최자 공정성, 51:56 근접/정확 배점) + 대표 확정(2026-06-09). 관련: docs/BACKEND.md(채점 구현), lib/types.ts(타입), 골드 상금 카드(components/Arena.tsx).

이 문서는 **100만원 최종 우승자 심사의 단일 기준(SSOT)**이다. 약관·랜딩 고지·백엔드 채점은 모두 이 문서를 따른다.


0. 확정 사항 (한 줄 요약)

  • 공정성 모델 = 주최측 배제 + 순수 누적 포인트. 점수는 정확도에서만 발생(참여 횟수에 직접 점수 없음).
  • 100만원 = 대회 통합 누적 포인트 1위 1인에게 최종 지급(경기마다 X).
  • 채점은 내 예측 vs 실제 경기 결과. AI(GPT·Claude·Gemini)는 재미·기준점이며 채점 대상이 아니다.

1. 공정성 3원칙

# 원칙 이유
P1. 주최측·임직원 배제 운영진/주최자는 수상 대상에서 제외 회의 30:36 우려("주최자가 모든 경기 참여 가능 → 유리") 직접 해소. 상금 이벤트 표준 약관
P2. 점수는 정확도에서만 참여 횟수 자체에는 점수를 주지 않음 (틀린 픽 = 0점) "많이 찍기" 어뷰징 무력화 — 부정확한 대량 참여는 보상이 0
P3. 누적 총점 1위 대회 전체 누적 포인트가 가장 높은 사람 대회 끝에 지급 → 1경기 운빨 우승은 누적으로 자연 추월됨. 꾸준한 정확도가 이김

누적이 운빨을 방어하는 이유: 단일 경기 최대 5점. 토너먼트(다수 경기) 누적에서는 성실·정확한 참여자가 반드시 앞선다. 그래서 별도 "최소 참여 경기 자격선"은 두지 않는다(불필요).


2. 경기별 적중 포인트 (정확도 기반)

예측 pick = {scoreA, scoreB, outcome} vs 실제 result = {scoreA, scoreB, outcome}.

등급 조건 포인트 (기본값·팀 확정 대상)
정확 스코어 양 팀 득점 완전 일치 (승패 당연 포함) 5
근접 (득실차) 승패 적중 + 득실차 일치 (정확 스코어 제외) 3
승패 적중 승/무/패만 일치 2
부분 근접 승패는 빗나갔으나 한 팀 득점 일치 1
빗나감 그 외 0
  • 단조 증가(정확 > 근접 > 승패 > 부분 > 빗나감)라 "더 정밀할수록 더 많은 점수"가 보장된다.
  • 무승부: 승패 적중이면서 득실차(=0) 일치 → 정확이 아니면 근접(3점).
  • 배점 수치는 황 본부장 확정 후 dev 전달(회의 05:48). 위는 기본 제안값.

야구(KBO/MLB) 판정 완화 (2026-07-22)

야구는 득점 범위가 넓어(0~12+) 축구 기준 그대로면 정확/근접/부분이 거의 나오지 않는다. 등급 체계·배점은 리그 공통으로 유지하고, 야구만 판정에 허용 오차 ±1(scoring.BASEBALL_TOLERANCE)을 둔다. 리그별 시상이므로 리그 간 점수 크기 차이는 문제되지 않는다.

등급 야구 조건 포인트
근접 승패 적중 + 득실차 오차 ≤1 3
부분 한 팀 득점 오차 ≤1 1

(정확·승패·빗나감 조건은 축구와 동일)

채점 알고리즘 (결정론적)

tol = 야구 ? 1 : 0
1) pick.scoreA == result.scoreA && pick.scoreB == result.scoreB  → 정확(5)
2) else if pick.outcome == result.outcome:
      |득실차 오차| <= tol ? 근접(3) : 승패(2)
3) else if |pick.scoreA - result.scoreA| <= tol || |pick.scoreB - result.scoreB| <= tol → 부분(1)
4) else → 0

3. 랭킹 (누적 총점 1위)

  • 1차 정렬: 누적 적중 포인트 합계(Σ) — 높을수록 상위.
  • 동점자 보정(tie-break, 순서대로):
    1. 정확 스코어 적중 횟수 (정밀성)
    2. 적중률 = 누적 포인트 ÷ 참여 경기 수 (효율)
    3. 참여 경기 수 (성실성)
    4. 최초 해당 점수 도달 시각 (먼저 달성한 쪽)
  • 동점 보정도 모두 같으면 약관에 따라 분할 또는 추첨.

참여 경기 수·적중률은 동점 보정용으로만 쓴다. 1차 순위는 어디까지나 순수 누적 포인트.


4. 자격 · 배제 (P1 구현)

  • 식별 단위 = 이메일(비로그인). 정규화: 소문자화, (옵션) Gmail ./+alias 정규화로 동일인 합산.
  • 배제 대상:
    • 임직원·운영진 이메일 블록리스트(STAFF_EMAILS).
    • 회사 도메인 자동 제외: @o2o.kr, @aio2o.* 등(STAFF_DOMAINS).
  • 알려진 한계 — 다중 이메일 어뷰징: 한 사람이 여러 메일로 참여 가능. 현 단계 비로그인 정책상 완전 차단 불가 → 약관에 "1인 1계정 원칙, 위반 시 실격" 명시 + 차기 본인 인증(휴대폰/소셜)으로 보완. 의심 계정은 운영자 수동 실격 가능.

5. 채점 대상 "결과"의 정의 (분쟁 방지)

팀 확인 필요 — 아래는 권장 기본값.

  • 정규시간(전·후반 + 추가시간) 종료 스코어 기준. 연장·승부차기 제외(예측은 90분 기준이 일반적).
  • 경기 취소/몰수/중단: 해당 경기 전원 채점 제외(자격 분모에서도 제외).
  • 무효 제출: 마감(킥오프) 이후 제출 무효. 동일 이메일 다중 제출 시 마감 전 마지막 1건만 유효.
  • 결과 입력 출처(공식 스코어 소스)와 정정 정책은 BACKEND.md에서 1개 소스로 고정.

6. 투명성 (회의 30:38 "오해 없이 사전 공지")

발행 전 랜딩/약관에 사전 고지:

  • 배점표(2장) · 배제 대상(4장) · 동점 규칙(3장) · 결과 기준=정규시간(5장) · 지급 방식(대회 통합 1인).
  • 본인 누적 포인트·현재 순위 열람(리더보드 = 차기 페이지에서).

7. 참조 구현 (TypeScript, lib/types.ts 정합)

import type { Outcome, UserPick } from "@/lib/types";

type MatchResult = { scoreA: number; scoreB: number; outcome: Outcome };

// 배점 — 팀 확정 후 이 상수만 교체
export const SCORE = { EXACT: 5, CLOSE: 3, OUTCOME: 2, PARTIAL: 1, MISS: 0 } as const;

export function scorePrediction(pick: UserPick, r: MatchResult): number {
  if (pick.scoreA === r.scoreA && pick.scoreB === r.scoreB) return SCORE.EXACT;
  if (pick.outcome === r.outcome) {
    return pick.scoreA - pick.scoreB === r.scoreA - r.scoreB ? SCORE.CLOSE : SCORE.OUTCOME;
  }
  if (pick.scoreA === r.scoreA || pick.scoreB === r.scoreB) return SCORE.PARTIAL;
  return SCORE.MISS;
}

// 배제 대상
const STAFF_EMAILS = new Set<string>([/* "ceo@…", … */]);
const STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"];
export function isExcluded(email: string): boolean {
  const e = email.trim().toLowerCase();
  return STAFF_EMAILS.has(e) || STAFF_DOMAINS.some((d) => e.endsWith("@" + d));
}

// 누적 집계 → 랭킹
export interface Standing {
  email: string;
  totalPoints: number;   // 1차 정렬
  exactCount: number;    // tie ①
  matchesPlayed: number; // tie ③ (적중률 분모)
  firstReachedAt: number;// tie ④ (epoch ms)
}
export function rank(standings: Standing[]): Standing[] {
  return standings
    .filter((s) => !isExcluded(s.email))
    .sort((a, b) =>
      b.totalPoints - a.totalPoints ||
      b.exactCount - a.exactCount ||
      b.totalPoints / b.matchesPlayed - a.totalPoints / a.matchesPlayed || // 적중률 tie②
      b.matchesPlayed - a.matchesPlayed ||
      a.firstReachedAt - b.firstReachedAt,
    );
}

UserPicklib/types.ts에 이미 정의됨({outcome, scoreA, scoreB}). 위 함수는 백엔드(Cloud Functions) 결과 입력 시 호출되어 사용자별 누적 포인트를 갱신한다(상세 BACKEND.md).


8. 미결정 / 팀 확정 대기

  1. 배점 수치SCORE 5/3/2/1/0 기본값 확정 (황 본부장, 회의 05:48). 부분근접(1점) 채택 여부 포함.
  2. 결과 기준 — 정규시간 90분 vs 연장 포함 (5장). 권장=정규시간.
  3. 공식 스코어 소스 — 결과 입력 단일 출처 + 정정 정책(BACKEND.md).
  4. 동률 최종 처리 — 분할 vs 추첨 (약관).
  5. 랜딩 카피 정합 — 골드 상금 카드 문구를 본 모델에 맞춤(아래).

골드 카드 카피 정합 (권장)

현재: "투표 참여율, 승률과 점수 정답률 점수로 최종 1등에게 증정" → 본 모델(참여 횟수는 점수 아님)과 맞추려면: "경기마다 승패·스코어를 맞혀 포인트를 쌓고, 누적 1위에게 최종 100만원" 권장.