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차 순위는 어디까지나 순수 누적 포인트.
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,
);
}
UserPick은lib/types.ts에 이미 정의됨({outcome, scoreA, scoreB}). 위 함수는 백엔드(Cloud Functions) 결과 입력 시 호출되어 사용자별 누적 포인트를 갱신한다(상세BACKEND.md).
8. 미결정 / 팀 확정 대기
- 배점 수치 —
SCORE5/3/2/1/0 기본값 확정 (황 본부장, 회의 05:48). 부분근접(1점) 채택 여부 포함. - 결과 기준 — 정규시간 90분 vs 연장 포함 (5장). 권장=정규시간.
- 공식 스코어 소스 — 결과 입력 단일 출처 + 정정 정책(
BACKEND.md). - 동률 최종 처리 — 분할 vs 추첨 (약관).
- 랜딩 카피 정합 — 골드 상금 카드 문구를 본 모델에 맞춤(아래).
골드 카드 카피 정합 (권장)
현재: "투표 참여율, 승률과 점수 정답률 점수로 최종 1등에게 증정" → 본 모델(참여 횟수는 점수 아님)과 맞추려면: "경기마다 승패·스코어를 맞혀 포인트를 쌓고, 누적 1위에게 최종 100만원" 권장.