# 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` 정합) ```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([/* "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, ); } ``` > `UserPick`은 `lib/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만원"** 권장.