import { resolveTranslationFallback } from './locale'; export const MARKET_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const; export type MarketLocale = (typeof MARKET_LOCALES)[number]; export type LocalizedText = Partial>; export type FootballMarketPeriod = 'FT' | 'HT' | 'SH' | 'OUTRIGHT'; export type MarketRenderType = 'standard' | 'correctScore' | 'unsupported'; export type MarketSettlementKind = | 'SCORE' | 'HANDICAP' | 'TOTAL' | 'ODD_EVEN' | 'CORRECT_SCORE' | 'OUTRIGHT' | 'MANUAL_STATS' | 'UNSUPPORTED'; export type MarketSelectionTemplate = { code: string; name: string; nameI18n: LocalizedText; odds: number; }; export type FootballMarketCatalogEntry = { marketKey: string; period: FootballMarketPeriod; sortOrder: number; defaultLineValue: number | null; defaultParams?: Record; allowSingle: boolean; allowParlay: boolean; showOnPlayer: boolean; settlementSupported: boolean; settlementKind: MarketSettlementKind; renderType: MarketRenderType; detailOrder: number | null; parlayOrder: number | null; i18nKey: string; adminI18nKey: string; nameI18n: LocalizedText; selectionTemplate: readonly MarketSelectionTemplate[]; }; export const FT_CORRECT_SCORE_TEMPLATE = [ 'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW', 'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2', 'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME', 'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3', 'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY', ] as const; export const HT_CORRECT_SCORE_TEMPLATE = [ 'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW', 'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME', 'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY', ] as const; function text(zh: string, en: string, ms: string): LocalizedText { return { 'zh-CN': zh, 'en-US': en, 'ms-MY': ms }; } function selection(code: string, zh: string, en: string, ms: string, odds: number): MarketSelectionTemplate { const nameI18n = text(zh, en, ms); return { code, name: zh, nameI18n, odds }; } function handicapName(side: 'home' | 'away', line: number, half = false) { const sideLabel = side === 'home' ? '主队' : '客队'; const value = side === 'home' ? line : -line; const lineText = value > 0 ? `+${value}` : `${value}`; return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`; } function totalName(side: 'over' | 'under', line: number, half = false) { const sideLabel = side === 'over' ? '大' : '小'; return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`; } function scoreName(code: string) { if (code === 'OTHER_HOME') return '其它主胜'; if (code === 'OTHER_DRAW') return '其它和局'; if (code === 'OTHER_AWAY') return '其它客胜'; return code.replace('SCORE_', '').replace('_', '-'); } function scoreSelection(code: string, odds: number): MarketSelectionTemplate { const display = scoreName(code); const en = code === 'OTHER_HOME' ? 'Any Other Home Win' : code === 'OTHER_DRAW' ? 'Any Other Draw' : code === 'OTHER_AWAY' ? 'Any Other Away Win' : display; const ms = code === 'OTHER_HOME' ? 'Lain-lain Menang Tuan Rumah' : code === 'OTHER_DRAW' ? 'Lain-lain Seri' : code === 'OTHER_AWAY' ? 'Lain-lain Menang Pelawat' : display; return selection(code, display, en, ms, odds); } const ftCorrectScoreSelections = FT_CORRECT_SCORE_TEMPLATE.map((code) => scoreSelection(code, 8)); const htCorrectScoreSelections = HT_CORRECT_SCORE_TEMPLATE.map((code) => scoreSelection(code, 6)); const teamTotalSelections = [ selection('OVER', totalName('over', 1.5), 'Over', 'Atas', 1.85), selection('UNDER', totalName('under', 1.5), 'Under', 'Bawah', 1.95), ] as const; const htFtSelections = [ selection('HOME_HOME', '主/主', 'Home/Home', 'Tuan Rumah/Tuan Rumah', 2.9), selection('HOME_DRAW', '主/和', 'Home/Draw', 'Tuan Rumah/Seri', 15), selection('HOME_AWAY', '主/客', 'Home/Away', 'Tuan Rumah/Pelawat', 26), selection('DRAW_HOME', '和/主', 'Draw/Home', 'Seri/Tuan Rumah', 4.8), selection('DRAW_DRAW', '和/和', 'Draw/Draw', 'Seri/Seri', 4.5), selection('DRAW_AWAY', '和/客', 'Draw/Away', 'Seri/Pelawat', 6.8), selection('AWAY_HOME', '客/主', 'Away/Home', 'Pelawat/Tuan Rumah', 21), selection('AWAY_DRAW', '客/和', 'Away/Draw', 'Pelawat/Seri', 15), selection('AWAY_AWAY', '客/客', 'Away/Away', 'Pelawat/Pelawat', 4.6), ] as const; const totalGoalsSelections = [ selection('TG_0_1', '0-1', '0-1', '0-1', 3.8), selection('TG_2_3', '2-3', '2-3', '2-3', 2.2), selection('TG_4_6', '4-6', '4-6', '4-6', 2.8), selection('TG_7_PLUS', '7+', '7+', '7+', 12), ] as const; const cornersHandicapSelections = [ selection('HOME', '主队角球', 'Home Corners', 'Sudut Tuan Rumah', 1.9), selection('AWAY', '客队角球', 'Away Corners', 'Sudut Pelawat', 1.9), ] as const; const cornersTotalSelections = [ selection('OVER', '大 8.5', 'Over', 'Atas', 1.85), selection('UNDER', '小 8.5', 'Under', 'Bawah', 1.95), ] as const; const cardsTotalSelections = [ selection('OVER', '大 3.5', 'Over', 'Atas', 1.85), selection('UNDER', '小 3.5', 'Under', 'Bawah', 1.95), ] as const; const unsupported = { allowSingle: true, allowParlay: false, showOnPlayer: false, settlementSupported: false, settlementKind: 'UNSUPPORTED' as const, renderType: 'unsupported' as const, detailOrder: null, parlayOrder: null, selectionTemplate: [] as readonly MarketSelectionTemplate[], }; export const FOOTBALL_MARKET_CATALOG = { FT_1X2: { marketKey: 'FT_1X2', period: 'FT', sortOrder: 1, defaultLineValue: null, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'SCORE', renderType: 'standard', detailOrder: 5, parlayOrder: 3, i18nKey: 'bet.market_ft_1x2', adminI18nKey: 'matchEditor.market.FT_1X2', nameI18n: text('全场 1X2', 'FT 1X2', '1X2 Penuh'), selectionTemplate: [ selection('HOME', '主', 'Home', 'Tuan Rumah', 2.5), selection('DRAW', '和', 'Draw', 'Seri', 3.2), selection('AWAY', '客', 'Away', 'Pelawat', 2.8), ], }, FT_HANDICAP: { marketKey: 'FT_HANDICAP', period: 'FT', sortOrder: 2, defaultLineValue: -0.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'HANDICAP', renderType: 'standard', detailOrder: 3, parlayOrder: 1, i18nKey: 'bet.market_ft_handicap', adminI18nKey: 'matchEditor.market.FT_HANDICAP', nameI18n: text('全场让球', 'FT Handicap', 'Handicap Penuh'), selectionTemplate: [ selection('HOME', handicapName('home', -0.5), 'Home', 'Tuan Rumah', 1.9), selection('AWAY', handicapName('away', -0.5), 'Away', 'Pelawat', 1.9), ], }, FT_OVER_UNDER: { marketKey: 'FT_OVER_UNDER', period: 'FT', sortOrder: 3, defaultLineValue: 2.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'TOTAL', renderType: 'standard', detailOrder: 4, parlayOrder: 2, i18nKey: 'bet.market_ft_ou', adminI18nKey: 'matchEditor.market.FT_OVER_UNDER', nameI18n: text('全场大小', 'FT Over/Under', 'Atas/Bawah Penuh'), selectionTemplate: [ selection('OVER', totalName('over', 2.5), 'Over', 'Atas', 1.85), selection('UNDER', totalName('under', 2.5), 'Under', 'Bawah', 1.95), ], }, FT_ODD_EVEN: { marketKey: 'FT_ODD_EVEN', period: 'FT', sortOrder: 4, defaultLineValue: null, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'ODD_EVEN', renderType: 'standard', detailOrder: 6, parlayOrder: 4, i18nKey: 'bet.market_ft_oe', adminI18nKey: 'matchEditor.market.FT_ODD_EVEN', nameI18n: text('全场单双', 'FT Odd/Even', 'Ganjil/Genap Penuh'), selectionTemplate: [ selection('ODD', '单', 'Odd', 'Ganjil', 1.9), selection('EVEN', '双', 'Even', 'Genap', 1.9), ], }, HT_1X2: { marketKey: 'HT_1X2', period: 'HT', sortOrder: 5, defaultLineValue: null, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'SCORE', renderType: 'standard', detailOrder: 9, parlayOrder: 7, i18nKey: 'bet.market_ht_1x2', adminI18nKey: 'matchEditor.market.HT_1X2', nameI18n: text('半场 1X2', 'HT 1X2', '1X2 Separuh'), selectionTemplate: [ selection('HOME', '主', 'Home', 'Tuan Rumah', 3), selection('DRAW', '和', 'Draw', 'Seri', 2), selection('AWAY', '客', 'Away', 'Pelawat', 3.5), ], }, HT_HANDICAP: { marketKey: 'HT_HANDICAP', period: 'HT', sortOrder: 6, defaultLineValue: -0.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'HANDICAP', renderType: 'standard', detailOrder: 7, parlayOrder: 5, i18nKey: 'bet.market_ht_handicap', adminI18nKey: 'matchEditor.market.HT_HANDICAP', nameI18n: text('半场让球', 'HT Handicap', 'Handicap Separuh'), selectionTemplate: [ selection('HOME', handicapName('home', -0.5, true), 'Home', 'Tuan Rumah', 1.9), selection('AWAY', handicapName('away', -0.5, true), 'Away', 'Pelawat', 1.9), ], }, HT_OVER_UNDER: { marketKey: 'HT_OVER_UNDER', period: 'HT', sortOrder: 7, defaultLineValue: 1.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'TOTAL', renderType: 'standard', detailOrder: 8, parlayOrder: 6, i18nKey: 'bet.market_ht_ou', adminI18nKey: 'matchEditor.market.HT_OVER_UNDER', nameI18n: text('半场大小', 'HT Over/Under', 'Atas/Bawah Separuh'), selectionTemplate: [ selection('OVER', totalName('over', 1.5, true), 'Over', 'Atas', 2), selection('UNDER', totalName('under', 1.5, true), 'Under', 'Bawah', 1.75), ], }, FT_CORRECT_SCORE: { marketKey: 'FT_CORRECT_SCORE', period: 'FT', sortOrder: 8, defaultLineValue: null, allowSingle: true, allowParlay: false, showOnPlayer: true, settlementSupported: true, settlementKind: 'CORRECT_SCORE', renderType: 'correctScore', detailOrder: 0, parlayOrder: null, i18nKey: 'bet.market_cs', adminI18nKey: 'matchEditor.market.FT_CORRECT_SCORE', nameI18n: text('全场波胆', 'FT Correct Score', 'Skor Tepat Penuh'), selectionTemplate: ftCorrectScoreSelections, }, HT_CORRECT_SCORE: { marketKey: 'HT_CORRECT_SCORE', period: 'HT', sortOrder: 9, defaultLineValue: null, allowSingle: true, allowParlay: false, showOnPlayer: true, settlementSupported: true, settlementKind: 'CORRECT_SCORE', renderType: 'correctScore', detailOrder: 1, parlayOrder: null, i18nKey: 'bet.market_ht_cs', adminI18nKey: 'matchEditor.market.HT_CORRECT_SCORE', nameI18n: text('上半场波胆', '1H Correct Score', 'Skor Tepat Separuh Pertama'), selectionTemplate: htCorrectScoreSelections, }, SH_CORRECT_SCORE: { marketKey: 'SH_CORRECT_SCORE', period: 'SH', sortOrder: 10, defaultLineValue: null, allowSingle: true, allowParlay: false, showOnPlayer: true, settlementSupported: true, settlementKind: 'CORRECT_SCORE', renderType: 'correctScore', detailOrder: 2, parlayOrder: null, i18nKey: 'bet.market_sh_cs', adminI18nKey: 'matchEditor.market.SH_CORRECT_SCORE', nameI18n: text('下半场波胆', '2H Correct Score', 'Skor Tepat Separuh Kedua'), selectionTemplate: htCorrectScoreSelections, }, OUTRIGHT_WINNER: { marketKey: 'OUTRIGHT_WINNER', period: 'OUTRIGHT', sortOrder: 1, defaultLineValue: null, allowSingle: true, allowParlay: false, showOnPlayer: true, settlementSupported: true, settlementKind: 'OUTRIGHT', renderType: 'standard', detailOrder: null, parlayOrder: null, i18nKey: 'bet.market_outright_winner', adminI18nKey: 'matchEditor.market.OUTRIGHT_WINNER', nameI18n: text('冠军', 'Outright Winner', 'Juara'), selectionTemplate: [], }, FT_TEAM_TOTAL_HOME: { marketKey: 'FT_TEAM_TOTAL_HOME', period: 'FT', sortOrder: 40, defaultLineValue: 1.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'TOTAL', renderType: 'standard', detailOrder: 10, parlayOrder: 8, i18nKey: 'bet.market_ft_team_total_home', adminI18nKey: 'matchEditor.market.FT_TEAM_TOTAL_HOME', nameI18n: text('主队进球大小', 'Home Team Total Goals', 'Jumlah Gol Tuan Rumah'), selectionTemplate: teamTotalSelections, }, FT_TEAM_TOTAL_AWAY: { marketKey: 'FT_TEAM_TOTAL_AWAY', period: 'FT', sortOrder: 41, defaultLineValue: 1.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'TOTAL', renderType: 'standard', detailOrder: 11, parlayOrder: 9, i18nKey: 'bet.market_ft_team_total_away', adminI18nKey: 'matchEditor.market.FT_TEAM_TOTAL_AWAY', nameI18n: text('客队进球大小', 'Away Team Total Goals', 'Jumlah Gol Pelawat'), selectionTemplate: teamTotalSelections, }, HT_FT: { marketKey: 'HT_FT', period: 'FT', sortOrder: 42, defaultLineValue: null, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'SCORE', renderType: 'standard', detailOrder: 12, parlayOrder: 10, i18nKey: 'bet.market_ht_ft', adminI18nKey: 'matchEditor.market.HT_FT', nameI18n: text('半全场', 'Half Time / Full Time', 'Separuh Masa / Penuh Masa'), selectionTemplate: htFtSelections, }, FT_TOTAL_GOALS: { marketKey: 'FT_TOTAL_GOALS', period: 'FT', sortOrder: 43, defaultLineValue: null, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'SCORE', renderType: 'standard', detailOrder: 13, parlayOrder: 11, i18nKey: 'bet.market_ft_total_goals', adminI18nKey: 'matchEditor.market.FT_TOTAL_GOALS', nameI18n: text('总进球数', 'Total Goals', 'Jumlah Gol'), selectionTemplate: totalGoalsSelections, }, FT_CORNERS_HANDICAP: { marketKey: 'FT_CORNERS_HANDICAP', period: 'FT', sortOrder: 60, defaultLineValue: -0.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'MANUAL_STATS', renderType: 'standard', detailOrder: 14, parlayOrder: 12, i18nKey: 'bet.market_ft_corners_handicap', adminI18nKey: 'matchEditor.market.FT_CORNERS_HANDICAP', nameI18n: text('全场角球让球', 'FT Corners Handicap', 'Handicap Sepakan Sudut Penuh'), selectionTemplate: cornersHandicapSelections, }, FT_CORNERS_OVER_UNDER: { marketKey: 'FT_CORNERS_OVER_UNDER', period: 'FT', sortOrder: 61, defaultLineValue: 8.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'MANUAL_STATS', renderType: 'standard', detailOrder: 15, parlayOrder: 13, i18nKey: 'bet.market_ft_corners_ou', adminI18nKey: 'matchEditor.market.FT_CORNERS_OVER_UNDER', nameI18n: text('全场角球大小', 'FT Corners Over/Under', 'Atas/Bawah Sudut Penuh'), selectionTemplate: cornersTotalSelections, }, FT_CARDS_OVER_UNDER: { marketKey: 'FT_CARDS_OVER_UNDER', period: 'FT', sortOrder: 70, defaultLineValue: 3.5, allowSingle: true, allowParlay: true, showOnPlayer: true, settlementSupported: true, settlementKind: 'MANUAL_STATS', renderType: 'standard', detailOrder: 16, parlayOrder: 14, i18nKey: 'bet.market_ft_cards_ou', adminI18nKey: 'matchEditor.market.FT_CARDS_OVER_UNDER', nameI18n: text('全场罚牌大小', 'FT Cards Over/Under', 'Atas/Bawah Kad Penuh'), selectionTemplate: cardsTotalSelections, }, } as const satisfies Record; export type FootballMarketType = keyof typeof FOOTBALL_MARKET_CATALOG; export const FEATURED_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG) .filter(([, entry]) => entry.detailOrder !== null && entry.detailOrder < 3) .sort(([, a], [, b]) => (a.detailOrder ?? 0) - (b.detailOrder ?? 0)) .map(([marketType]) => marketType) as FootballMarketType[]; export const DETAIL_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG) .filter(([, entry]) => entry.detailOrder !== null) .sort(([, a], [, b]) => (a.detailOrder ?? 0) - (b.detailOrder ?? 0)) .map(([marketType]) => marketType) as FootballMarketType[]; export const GRID_MARKET_TYPES = DETAIL_MARKET_TYPES.filter( (marketType) => !FEATURED_MARKET_TYPES.includes(marketType), ); export const PARLAY_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG) .filter(([, entry]) => entry.parlayOrder !== null && entry.allowParlay) .sort(([, a], [, b]) => (a.parlayOrder ?? 0) - (b.parlayOrder ?? 0)) .map(([marketType]) => marketType) as FootballMarketType[]; export const DEFAULT_MARKET_EXCLUDED_TYPES = [ 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE', ] as const satisfies readonly FootballMarketType[]; export const DEFAULT_MARKET_TYPES = Object.entries(FOOTBALL_MARKET_CATALOG) .filter( ([marketType, entry]) => entry.showOnPlayer && entry.marketKey !== 'OUTRIGHT_WINNER' && !(DEFAULT_MARKET_EXCLUDED_TYPES as readonly string[]).includes(marketType), ) .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) .map(([marketType]) => marketType) as FootballMarketType[]; export const MARKET_I18N_KEY: Record = Object.fromEntries( Object.entries(FOOTBALL_MARKET_CATALOG).map(([marketType, entry]) => [ marketType, entry.i18nKey, ]), ); export function isFootballMarketType(marketType: string): marketType is FootballMarketType { return marketType in FOOTBALL_MARKET_CATALOG; } export function isCorrectScoreMarketType(marketType: string) { return ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'].includes(marketType); } export function isSettlementSupportedMarketType(marketType: string) { return isFootballMarketType(marketType) && FOOTBALL_MARKET_CATALOG[marketType].settlementSupported; } export function marketUsesLineValue(marketType: string) { return isFootballMarketType(marketType) && FOOTBALL_MARKET_CATALOG[marketType].defaultLineValue !== null; } export function sanitizeLocalizedText(input: unknown): LocalizedText { if (!input || typeof input !== 'object') return {}; const out: LocalizedText = {}; for (const locale of MARKET_LOCALES) { const raw = (input as Record)[locale]; if (raw == null) continue; const value = String(raw).trim(); if (value) out[locale] = value; } return out; } export function resolveMarketText(input: unknown, locale: string, fallback?: LocalizedText | string | null) { const map = sanitizeLocalizedText(input); if (fallback && typeof fallback === 'object') { Object.assign(map, Object.fromEntries( Object.entries(fallback).filter(([key]) => map[key as MarketLocale] == null), )); } const resolved = resolveTranslationFallback(map, locale); if (resolved) return resolved; if (typeof fallback === 'string') return fallback; return ''; } export function defaultMarketName(marketType: string, locale = 'zh-CN') { if (!isFootballMarketType(marketType)) return marketType; return resolveMarketText(FOOTBALL_MARKET_CATALOG[marketType].nameI18n, locale, marketType); } export function defaultSelectionName( marketType: string, selectionCode: string, locale = 'zh-CN', ) { if (!isFootballMarketType(marketType)) return selectionCode; const hit = FOOTBALL_MARKET_CATALOG[marketType].selectionTemplate.find( (s) => s.code === selectionCode, ); return hit ? resolveMarketText(hit.nameI18n, locale, hit.name) : selectionCode; } export function buildMarketLineKey( marketType: string, lineValue?: number | string | null, params?: Record | null, ) { const line = lineValue == null || lineValue === '' ? 'none' : Number(lineValue).toFixed(2); const paramPairs = Object.entries(params ?? {}) .filter(([, value]) => value != null && value !== '') .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `${key}:${String(value)}`) .join('|'); return paramPairs ? `${marketType}:${line}:${paramPairs}` : `${marketType}:${line}`; } export function buildMarketTemplate(marketType: string): FootballMarketCatalogEntry { if (!isFootballMarketType(marketType)) { throw new Error(`Unknown football market type: ${marketType}`); } return FOOTBALL_MARKET_CATALOG[marketType]; }