# 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). 위는 기본 제안값.

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

```
1) pick.scoreA == result.scoreA && pick.scoreB == result.scoreB  → 정확(5)
2) else if pick.outcome == result.outcome:
      (pick.scoreA - pick.scoreB) == (result.scoreA - result.scoreB) ? 근접(3) : 승패(2)
3) else if pick.scoreA == result.scoreA || pick.scoreB == result.scoreB → 부분(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<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,
    );
}
```

> `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만원"** 권장.
