260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
// 1차 mock 데이터 — Firebase 연결 전까지 사용.
|
|
// ⚠️ AI 예측은 플레이스홀더. 발행 전 GPT/Claude/Gemini 실제 출력(JSON)으로 교체할 것.
|
|
// 경기 일정은 lib/schedule.ts(Group A 정식 일정, KST)에서 자동 반영.
|
|
//
|
|
// 멀티 경기(06.09 회의): 모든 경기에 3 AI 예측 + Crowd 가 있어야 대시보드/상세가 깨지지 않는다.
|
|
// 체코전(FEATURED)은 큐레이션 값, 그 외 경기는 matchId 시드 결정론적 생성 (Math.random 미사용).
|
|
import type { AIPrediction, CrowdStats, Match, ModelName, Outcome } from "./types";
|
|
import { FEATURED, GROUP_A } from "./schedule";
|
|
import { type Lang, teamShort } from "./i18n";
|
|
|
|
// 체코전 — 실제 3모델 API 출력 (2026-06-10 캐노니컬 스냅샷: gpt-5.5 / claude-opus-4-8 / gemini-2.5-flash)
|
|
// 결과: GPT 무 1-1(conf45) / Claude 무 1-1(conf55) / Gemini 한국 2-1(conf60) — 둘은 무승부, Gemini만 한국 승리
|
|
const FEATURED_REASONS: Record<Lang, Record<ModelName, string>> = {
|
|
ko: {
|
|
GPT: "전력과 압박 강도 모두 팽팽",
|
|
Claude: "양 팀 전력 비슷, 박빙 무승부",
|
|
Gemini: "이강인·손흥민 공격력, 체코 수비에 근소 우세",
|
|
},
|
|
en: {
|
|
GPT: "Power and pressing — dead even",
|
|
Claude: "Evenly matched, a tight draw likely",
|
|
Gemini: "Korea's attack edges a stubborn Czech defense",
|
|
},
|
|
};
|
|
|
|
export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT)
|
|
|
|
// 데모용: 실제 운영은 투표 오픈 = 킥오프 D-2(opensAt). 팀 데모에서 픽이 막히지 않게
|
|
// true면 오픈 게이트를 무시하고 즉시 참여 가능. 운영 배포 시 false 로.
|
|
export const DEMO_FORCE_OPEN = true;
|
|
|
|
// 체코전 — 실제 3모델 API 출력 (2026-06-10). reasonShort는 getPredictions에서 FEATURED_REASONS로 언어별 대체.
|
|
const FEATURED_PREDICTIONS: AIPrediction[] = [
|
|
{
|
|
matchId: MATCH.matchId,
|
|
model: "GPT", // gpt-5.5: 무승부 1-1 (conf 45)
|
|
outcome: "DRAW",
|
|
scoreA: 1,
|
|
scoreB: 1,
|
|
confidencePct: 45,
|
|
reasonShort: FEATURED_REASONS.ko.GPT,
|
|
generatedAt: "2026-06-10",
|
|
},
|
|
{
|
|
matchId: MATCH.matchId,
|
|
model: "Claude", // claude-opus-4-8: 무승부 1-1 (conf 55)
|
|
outcome: "DRAW",
|
|
scoreA: 1,
|
|
scoreB: 1,
|
|
confidencePct: 55,
|
|
reasonShort: FEATURED_REASONS.ko.Claude,
|
|
generatedAt: "2026-06-10",
|
|
},
|
|
{
|
|
matchId: MATCH.matchId,
|
|
model: "Gemini", // gemini-2.5-flash: 한국 승 2-1 (conf 60)
|
|
outcome: "TEAM_A_WIN",
|
|
scoreA: 2,
|
|
scoreB: 1,
|
|
confidencePct: 60,
|
|
reasonShort: FEATURED_REASONS.ko.Gemini,
|
|
generatedAt: "2026-06-10",
|
|
},
|
|
];
|
|
|
|
// ── 결정론적 생성 (matchId 시드) ──────────────────────────────
|
|
function hash(s: string): number {
|
|
let h = 2166136261;
|
|
for (let i = 0; i < s.length; i++) {
|
|
h ^= s.charCodeAt(i);
|
|
h = Math.imul(h, 16777619);
|
|
}
|
|
return h >>> 0;
|
|
}
|
|
|
|
const MODELS: ModelName[] = ["GPT", "Claude", "Gemini"];
|
|
|
|
// 예측에 사용한 실제 모델 버전 (UI 투명성 표기용). 갱신 시 .env·AI-PREDICTIONS.md와 함께 변경.
|
|
export const MODEL_VERSIONS: Record<ModelName, string> = {
|
|
GPT: "gpt-5.5",
|
|
Claude: "claude-opus-4-8",
|
|
Gemini: "gemini-2.5-flash",
|
|
};
|
|
|
|
// AI 셋이 갈리도록 만든 결과 조합 (홈/원정 균형)
|
|
const OUTCOME_SETS: Outcome[][] = [
|
|
["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"],
|
|
["TEAM_A_WIN", "TEAM_A_WIN", "DRAW"],
|
|
["TEAM_B_WIN", "DRAW", "TEAM_A_WIN"],
|
|
["TEAM_A_WIN", "DRAW", "TEAM_A_WIN"],
|
|
["DRAW", "TEAM_B_WIN", "TEAM_A_WIN"],
|
|
];
|
|
|
|
const SCORE_BY_OUTCOME: Record<Outcome, [number, number][]> = {
|
|
TEAM_A_WIN: [[2, 1], [1, 0], [2, 0], [3, 1]],
|
|
DRAW: [[1, 1], [0, 0], [2, 2]],
|
|
TEAM_B_WIN: [[0, 1], [1, 2], [0, 2], [1, 3]],
|
|
};
|
|
|
|
function reasonFor(match: Match, outcome: Outcome, seed: number, lang: Lang): string {
|
|
const win =
|
|
outcome === "TEAM_A_WIN"
|
|
? teamShort(match.teamA, lang)
|
|
: outcome === "TEAM_B_WIN"
|
|
? teamShort(match.teamB, lang)
|
|
: null;
|
|
const pools =
|
|
lang === "en"
|
|
? {
|
|
win: win
|
|
? [
|
|
`${win}'s wing speed edges it`,
|
|
`${win} just needs one set piece`,
|
|
`${win} starts on the front foot`,
|
|
`${win}'s finishing decides it`,
|
|
]
|
|
: [],
|
|
draw: [
|
|
"A tight midfield battle to the end",
|
|
"Both sides carry a real threat",
|
|
"The first goal decides everything",
|
|
],
|
|
}
|
|
: {
|
|
win: win
|
|
? [
|
|
`${win} 측면 스피드가 한 끗 위`,
|
|
`${win} 세트피스 한 방이면 끝`,
|
|
`${win} 초반 기세에서 앞선다`,
|
|
`${win} 결정력이 승부를 가른다`,
|
|
]
|
|
: [],
|
|
draw: [
|
|
"중원 힘싸움이 끝까지 팽팽",
|
|
"양 팀 다 한 방 있는 진검승부",
|
|
"first goal이 모든 걸 가른다",
|
|
],
|
|
};
|
|
const pool = win ? pools.win : pools.draw;
|
|
return pool[seed % pool.length];
|
|
}
|
|
|
|
function generatePredictions(match: Match, lang: Lang): AIPrediction[] {
|
|
const base = hash(match.matchId);
|
|
const outcomes = OUTCOME_SETS[base % OUTCOME_SETS.length];
|
|
return MODELS.map((model, i) => {
|
|
const seed = hash(match.matchId + model);
|
|
const outcome = outcomes[i];
|
|
const scores = SCORE_BY_OUTCOME[outcome];
|
|
const [scoreA, scoreB] = scores[seed % scores.length];
|
|
const confidencePct =
|
|
outcome === "DRAW" ? 33 + (seed % 13) : 46 + (seed % 25); // draw 33~45, win 46~70
|
|
return {
|
|
matchId: match.matchId,
|
|
model,
|
|
outcome,
|
|
scoreA,
|
|
scoreB,
|
|
confidencePct,
|
|
reasonShort: reasonFor(match, outcome, seed, lang),
|
|
generatedAt: "2026-06-08",
|
|
};
|
|
});
|
|
}
|
|
|
|
// 나머지 5경기 — 실제 3모델 API 출력 (2026-06-10, gpt-5.5 / gemini-3.5-flash / claude-opus-4-8 직접)
|
|
// scoreA=teamA(앞 팀), scoreB=teamB(뒤 팀). outcome: TEAM_A_WIN / DRAW / TEAM_B_WIN.
|
|
type Curated = {
|
|
model: ModelName;
|
|
outcome: Outcome;
|
|
scoreA: number;
|
|
scoreB: number;
|
|
confidencePct: number;
|
|
ko: string;
|
|
en: string;
|
|
};
|
|
const CURATED: Record<string, Curated[]> = {
|
|
// 개막전 — 멕시코 vs 남아공
|
|
A_MEX_RSA_20260612: [
|
|
{ model: "GPT", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 1, confidencePct: 62, ko: "멕시코 전력 우위, 남아공 수비 불안", en: "Mexico's edge vs a shaky South Africa defense" },
|
|
{ model: "Claude", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 0, confidencePct: 64, ko: "개최국 멕시코의 홈 압박과 측면 우위", en: "Host Mexico's home press and wing edge" },
|
|
{ model: "Gemini", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 0, confidencePct: 75, ko: "멕시코의 홈 이점과 객관적 전력 우세", en: "Mexico's home advantage and clear quality" },
|
|
],
|
|
// 체코 vs 남아공
|
|
A_CZE_RSA_20260619: [
|
|
{ model: "GPT", outcome: "TEAM_A_WIN", scoreA: 1, scoreB: 0, confidencePct: 56, ko: "체코의 조직력·결정력이 근소 우위", en: "Czechia's structure & finishing just edge it" },
|
|
{ model: "Claude", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 0, confidencePct: 60, ko: "체코의 세트피스와 피지컬 우위", en: "Czechia's set pieces and physicality" },
|
|
{ model: "Gemini", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 1, confidencePct: 65, ko: "체코의 전력 우세와 골 결정력 차이", en: "Czechia's quality and finishing decide it" },
|
|
],
|
|
// 멕시코 vs 한국 — 모델 갈림
|
|
A_MEX_KOR_20260619: [
|
|
{ model: "GPT", outcome: "TEAM_A_WIN", scoreA: 2, scoreB: 1, confidencePct: 55, ko: "홈 압박으로 멕시코가 한 골 차 우세", en: "Home press gives Mexico a one-goal edge" },
|
|
{ model: "Claude", outcome: "DRAW", scoreA: 1, scoreB: 1, confidencePct: 50, ko: "홈의 멕시코, 역습의 한국 — 균형", en: "Mexico's press vs Korea's counters — even" },
|
|
{ model: "Gemini", outcome: "DRAW", scoreA: 1, scoreB: 1, confidencePct: 65, ko: "홈 멕시코와 한국 유럽파의 접전 무승부", en: "Home Mexico vs Korea's Europe stars — a draw" },
|
|
],
|
|
// 체코 vs 멕시코
|
|
A_CZE_MEX_20260625: [
|
|
{ model: "GPT", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 58, ko: "멕시코의 홈 이점과 공격력 우세", en: "Mexico's home edge and attack prevail" },
|
|
{ model: "Claude", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 57, ko: "멕시코의 템포와 개인기가 체코를 넘는다", en: "Mexico's tempo and flair edge Czechia" },
|
|
{ model: "Gemini", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 60, ko: "멕시코의 경험이 승리를 견인", en: "Mexico's experience drives the win" },
|
|
],
|
|
// 남아공 vs 한국 — 16강 길목
|
|
A_RSA_KOR_20260625: [
|
|
{ model: "GPT", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 58, ko: "한국의 전방 결정력과 큰 무대 경험 우세", en: "Korea's finishing and big-stage edge" },
|
|
{ model: "Claude", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 62, ko: "16강 길목, 한국의 화력이 남아공을 압도", en: "With the last-16 on the line, Korea's firepower tells" },
|
|
{ model: "Gemini", outcome: "TEAM_B_WIN", scoreA: 1, scoreB: 2, confidencePct: 75, ko: "스쿼드 전력 우세한 한국이 경기 주도", en: "Korea's stronger squad dictates the game" },
|
|
],
|
|
};
|
|
|
|
export function getPredictions(match: Match, lang: Lang = "ko"): AIPrediction[] {
|
|
if (match.matchId === FEATURED.matchId) {
|
|
return FEATURED_PREDICTIONS.map((p) => ({
|
|
...p,
|
|
reasonShort: FEATURED_REASONS[lang][p.model],
|
|
}));
|
|
}
|
|
const cur = CURATED[match.matchId];
|
|
if (cur) {
|
|
return cur.map((c) => ({
|
|
matchId: match.matchId,
|
|
model: c.model,
|
|
outcome: c.outcome,
|
|
scoreA: c.scoreA,
|
|
scoreB: c.scoreB,
|
|
confidencePct: c.confidencePct,
|
|
reasonShort: lang === "en" ? c.en : c.ko,
|
|
generatedAt: "2026-06-10",
|
|
}));
|
|
}
|
|
return generatePredictions(match, lang);
|
|
}
|
|
|
|
export function getCrowd(match: Match): CrowdStats {
|
|
if (match.matchId === FEATURED.matchId) {
|
|
return { matchId: MATCH.matchId, total: 1284, teamAWin: 616, draw: 347, teamBWin: 321 };
|
|
}
|
|
const base = hash(match.matchId + "crowd");
|
|
const total = 240 + (base % 1400);
|
|
const a = 28 + (base % 40); // 28~67%
|
|
const d = 18 + ((base >> 3) % 22); // 18~39%
|
|
const teamAWin = Math.round((total * a) / 100);
|
|
const draw = Math.round((total * Math.min(d, 100 - a - 5)) / 100);
|
|
const teamBWin = total - teamAWin - draw;
|
|
return { matchId: match.matchId, total, teamAWin, draw, teamBWin };
|
|
}
|
|
|
|
// 경기별 공유 딥링크 (D8)
|
|
const ORIGIN = "https://triplepick-web.vercel.app";
|
|
export function matchUrl(matchId: string): string {
|
|
return `${ORIGIN}/match/${matchId}`;
|
|
}
|
|
|
|
// ── 하위 호환 (체코전 단일 참조용) ──
|
|
export const AI_PREDICTIONS: AIPrediction[] = FEATURED_PREDICTIONS;
|
|
export const CROWD: CrowdStats = getCrowd(FEATURED);
|
|
export const LANDING_URL = matchUrl(FEATURED.matchId);
|
|
|
|
// schedule re-export (페이지에서 한 곳에서 import)
|
|
export { GROUP_A };
|