181 lines
8.6 KiB
Markdown
181 lines
8.6 KiB
Markdown
# 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<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만원"** 권장.
|