195 lines
6.4 KiB
TypeScript
195 lines
6.4 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";
|
|
|
|
// 체코전 큐레이션 근거 (KO/EN)
|
|
const FEATURED_REASONS: Record<Lang, Record<ModelName, string>> = {
|
|
ko: {
|
|
GPT: "한국 측면 스피드가 한 끗 위",
|
|
Claude: "중원 힘싸움이 끝까지 팽팽",
|
|
Gemini: "체코 역습 한 방이 매섭다",
|
|
},
|
|
en: {
|
|
GPT: "Korea's wing speed edges it",
|
|
Claude: "A tight midfield battle to the end",
|
|
Gemini: "Czechia's counter has real bite",
|
|
},
|
|
};
|
|
|
|
export const MATCH = FEATURED; // 대표 경기 = 한국 vs 체코 (schedule SSOT)
|
|
|
|
// 데모용: 실제 운영은 투표 오픈 = 킥오프 D-2(opensAt). 팀 데모에서 픽이 막히지 않게
|
|
// true면 오픈 게이트를 무시하고 즉시 참여 가능. 운영 배포 시 false 로.
|
|
export const DEMO_FORCE_OPEN = true;
|
|
|
|
// 체코전 큐레이션 예측 (D3: reason 짧고 또렷이)
|
|
const FEATURED_PREDICTIONS: AIPrediction[] = [
|
|
{
|
|
matchId: MATCH.matchId,
|
|
model: "GPT",
|
|
outcome: "TEAM_A_WIN",
|
|
scoreA: 2,
|
|
scoreB: 1,
|
|
confidencePct: 62,
|
|
reasonShort: "한국 측면 스피드가 한 끗 위",
|
|
generatedAt: "2026-06-08",
|
|
},
|
|
{
|
|
matchId: MATCH.matchId,
|
|
model: "Claude",
|
|
outcome: "DRAW",
|
|
scoreA: 1,
|
|
scoreB: 1,
|
|
confidencePct: 41,
|
|
reasonShort: "중원 힘싸움이 끝까지 팽팽",
|
|
generatedAt: "2026-06-08",
|
|
},
|
|
{
|
|
matchId: MATCH.matchId,
|
|
model: "Gemini",
|
|
outcome: "TEAM_B_WIN",
|
|
scoreA: 0,
|
|
scoreB: 2,
|
|
confidencePct: 28,
|
|
reasonShort: "체코 역습 한 방이 매섭다",
|
|
generatedAt: "2026-06-08",
|
|
},
|
|
];
|
|
|
|
// ── 결정론적 생성 (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"];
|
|
|
|
// 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",
|
|
};
|
|
});
|
|
}
|
|
|
|
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],
|
|
}));
|
|
}
|
|
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 };
|