import Decimal from 'decimal.js'; export type SelectionResult = 'WIN' | 'HALF_WIN' | 'PUSH' | 'HALF_LOSE' | 'LOSE' | 'VOID'; export interface ScoreInput { htHome: number; htAway: number; ftHome: number; ftAway: number; } export interface MatchStatsInput { homeCorners?: number | null; awayCorners?: number | null; homeYellowCards?: number | null; awayYellowCards?: number | null; homeRedCards?: number | null; awayRedCards?: number | null; homeCards?: number | null; awayCards?: number | null; } export interface SettlementInput { marketType: string; selectionCode: string; handicapLine?: number | null; totalLine?: number | null; score: ScoreInput; stats?: MatchStatsInput | null; templateScores?: string[]; /** 冠军盘:获胜球队 code,如 FRA、BRA */ winnerTeamCode?: string | null; } export function getShScore(score: ScoreInput): { home: number; away: number } { return { home: score.ftHome - score.htHome, away: score.ftAway - score.htAway, }; } function isQuarterLine(line: number): boolean { const frac = Math.abs(line % 1); return Math.abs(frac - 0.25) < 0.001 || Math.abs(frac - 0.75) < 0.001; } function splitQuarterLine(line: number): [number, number] { const sign = line >= 0 ? 1 : -1; const abs = Math.abs(line); const lower = Math.floor(abs * 2) / 2 * sign; const upper = (Math.floor(abs * 2) + 1) / 2 * sign; if (Math.abs(abs % 1 - 0.25) < 0.001) { return [lower, upper]; } // 0.75 case: e.g. -0.75 => -0.5 and -1 const l = Math.floor(abs) * sign; const u = (Math.floor(abs) + 0.5) * sign; return abs % 1 > 0.5 ? [l, u] : [lower, upper]; } function settleHandicap( teamGoals: number, oppGoals: number, handicap: number, isHome: boolean, ): SelectionResult { const adj = teamGoals + handicap - oppGoals; if (!isQuarterLine(handicap)) { if (adj > 0) return 'WIN'; if (adj === 0) return 'PUSH'; return 'LOSE'; } const [line1, line2] = splitQuarterLine(handicap); const r1 = teamGoals + line1 - oppGoals; const r2 = teamGoals + line2 - oppGoals; const results: SelectionResult[] = []; for (const r of [r1, r2]) { if (r > 0) results.push('WIN'); else if (r === 0) results.push('PUSH'); else results.push('LOSE'); } const winCount = results.filter((r) => r === 'WIN').length; const loseCount = results.filter((r) => r === 'LOSE').length; if (winCount === 2) return 'WIN'; if (loseCount === 2) return 'LOSE'; if (winCount === 1 && loseCount === 0) return 'HALF_WIN'; if (loseCount === 1 && winCount === 0) return 'HALF_LOSE'; return 'PUSH'; } function settleOverUnder( totalGoals: number, line: number, isOver: boolean, ): SelectionResult { if (!isQuarterLine(line)) { if (isOver) { if (totalGoals > line) return 'WIN'; if (totalGoals === line) return 'PUSH'; return 'LOSE'; } if (totalGoals < line) return 'WIN'; if (totalGoals === line) return 'PUSH'; return 'LOSE'; } const [line1, line2] = splitQuarterLine(line); const r1 = settleOverUnder(totalGoals, line1, isOver); const r2 = settleOverUnder(totalGoals, line2, isOver); const winCount = [r1, r2].filter((r) => r === 'WIN').length; const loseCount = [r1, r2].filter((r) => r === 'LOSE').length; if (winCount === 2) return 'WIN'; if (loseCount === 2) return 'LOSE'; if (winCount === 1) return 'HALF_WIN'; if (loseCount === 1) return 'HALF_LOSE'; return 'PUSH'; } function parseScoreCode(code: string): { home: number; away: number } | null { const match = code.match(/SCORE_(\d+)_(\d+)/); if (match) return { home: parseInt(match[1]), away: parseInt(match[2]) }; return null; } function settleCorrectScore( home: number, away: number, selectionCode: string, templateScores: string[], ): SelectionResult { const parsed = parseScoreCode(selectionCode); if (parsed) { return parsed.home === home && parsed.away === away ? 'WIN' : 'LOSE'; } const actualKey = `SCORE_${home}_${away}`; if (templateScores.includes(actualKey)) { return 'LOSE'; } const isDraw = home === away; const isHomeWin = home > away; if (selectionCode === 'OTHER_DRAW' && isDraw) return 'WIN'; if (selectionCode === 'OTHER_HOME' && isHomeWin) return 'WIN'; if (selectionCode === 'OTHER_AWAY' && !isHomeWin && !isDraw) return 'WIN'; return 'LOSE'; } function resultSide(home: number, away: number): 'HOME' | 'DRAW' | 'AWAY' { if (home > away) return 'HOME'; if (home < away) return 'AWAY'; return 'DRAW'; } function settleHtFt(score: ScoreInput, selectionCode: string): SelectionResult { const expected = `${resultSide(score.htHome, score.htAway)}_${resultSide(score.ftHome, score.ftAway)}`; return selectionCode === expected ? 'WIN' : 'LOSE'; } function settleTotalGoalsRange(totalGoals: number, selectionCode: string): SelectionResult { if (selectionCode === 'TG_0_1') return totalGoals <= 1 ? 'WIN' : 'LOSE'; if (selectionCode === 'TG_2_3') return totalGoals >= 2 && totalGoals <= 3 ? 'WIN' : 'LOSE'; if (selectionCode === 'TG_4_6') return totalGoals >= 4 && totalGoals <= 6 ? 'WIN' : 'LOSE'; if (selectionCode === 'TG_7_PLUS') return totalGoals >= 7 ? 'WIN' : 'LOSE'; const exact = selectionCode.match(/^GOALS_(\d+)$/); if (exact) return totalGoals === Number(exact[1]) ? 'WIN' : 'LOSE'; const exactPlus = selectionCode.match(/^GOALS_(\d+)_PLUS$/); if (exactPlus) return totalGoals >= Number(exactPlus[1]) ? 'WIN' : 'LOSE'; return 'LOSE'; } function requireStat(value: number | null | undefined, field: keyof MatchStatsInput): number { if (value == null || Number.isNaN(value)) { throw new Error(`SETTLEMENT_STAT_MISSING:${field}`); } return value; } export function settleSelection(input: SettlementInput): SelectionResult { const { marketType, selectionCode, handicapLine, totalLine, score } = input; const stats = input.stats ?? {}; const templates = input.templateScores ?? []; switch (marketType) { case 'FT_1X2': { if (selectionCode === 'HOME') return score.ftHome > score.ftAway ? 'WIN' : 'LOSE'; if (selectionCode === 'DRAW') return score.ftHome === score.ftAway ? 'WIN' : 'LOSE'; if (selectionCode === 'AWAY') return score.ftHome < score.ftAway ? 'WIN' : 'LOSE'; break; } case 'HT_1X2': { if (selectionCode === 'HOME') return score.htHome > score.htAway ? 'WIN' : 'LOSE'; if (selectionCode === 'DRAW') return score.htHome === score.htAway ? 'WIN' : 'LOSE'; if (selectionCode === 'AWAY') return score.htHome < score.htAway ? 'WIN' : 'LOSE'; break; } case 'FT_ODD_EVEN': { const total = score.ftHome + score.ftAway; const isOdd = total % 2 === 1; if (selectionCode === 'ODD') return isOdd ? 'WIN' : 'LOSE'; if (selectionCode === 'EVEN') return !isOdd ? 'WIN' : 'LOSE'; break; } case 'FT_HANDICAP': { const line = handicapLine ?? 0; const isHome = selectionCode === 'HOME'; const goals = isHome ? score.ftHome : score.ftAway; const opp = isHome ? score.ftAway : score.ftHome; return settleHandicap(goals, opp, isHome ? line : -line, isHome); } case 'HT_HANDICAP': { const line = handicapLine ?? 0; const isHome = selectionCode === 'HOME'; const goals = isHome ? score.htHome : score.htAway; const opp = isHome ? score.htAway : score.htHome; return settleHandicap(goals, opp, isHome ? line : -line, isHome); } case 'FT_OVER_UNDER': { const total = score.ftHome + score.ftAway; return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER'); } case 'HT_OVER_UNDER': { const total = score.htHome + score.htAway; return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER'); } case 'FT_TEAM_TOTAL_HOME': return settleOverUnder(score.ftHome, totalLine ?? 0, selectionCode === 'OVER'); case 'FT_TEAM_TOTAL_AWAY': return settleOverUnder(score.ftAway, totalLine ?? 0, selectionCode === 'OVER'); case 'HT_FT': return settleHtFt(score, selectionCode); case 'FT_TOTAL_GOALS': return settleTotalGoalsRange(score.ftHome + score.ftAway, selectionCode); case 'FT_CORNERS_HANDICAP': { const homeCorners = requireStat(stats.homeCorners, 'homeCorners'); const awayCorners = requireStat(stats.awayCorners, 'awayCorners'); const line = handicapLine ?? 0; const isHome = selectionCode === 'HOME'; const corners = isHome ? homeCorners : awayCorners; const opp = isHome ? awayCorners : homeCorners; return settleHandicap(corners, opp, isHome ? line : -line, isHome); } case 'FT_CORNERS_OVER_UNDER': { const total = requireStat(stats.homeCorners, 'homeCorners') + requireStat(stats.awayCorners, 'awayCorners'); return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER'); } case 'FT_CARDS_OVER_UNDER': { const total = requireStat(stats.homeCards, 'homeCards') + requireStat(stats.awayCards, 'awayCards'); return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER'); } case 'FT_CORRECT_SCORE': return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates); case 'HT_CORRECT_SCORE': return settleCorrectScore(score.htHome, score.htAway, selectionCode, templates); case 'SH_CORRECT_SCORE': { const sh = getShScore(score); return settleCorrectScore(sh.home, sh.away, selectionCode, templates); } case 'OUTRIGHT_WINNER': if (!input.winnerTeamCode) return 'LOSE'; return selectionCode === input.winnerTeamCode ? 'WIN' : 'LOSE'; } return 'LOSE'; } export function calculatePayout( stake: Decimal | number, odds: Decimal | number, result: SelectionResult, ): Decimal { const s = new Decimal(stake); const o = new Decimal(odds); switch (result) { case 'WIN': return s.mul(o); case 'HALF_WIN': return s.div(2).mul(o).add(s.div(2)); case 'PUSH': case 'VOID': return s; case 'HALF_LOSE': return s.div(2); case 'LOSE': return new Decimal(0); default: return new Decimal(0); } } export function calculateParlayPayout( stake: Decimal | number, selections: Array<{ odds: Decimal | number; result: SelectionResult }>, ): { betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal; effectiveOdds: Decimal } { const s = new Decimal(stake); if (selections.some((sel) => sel.result === 'LOSE')) { return { betResult: 'LOST', payout: new Decimal(0), effectiveOdds: new Decimal(0) }; } let combinedOdds = new Decimal(1); for (const sel of selections) { if (sel.result === 'WIN') { combinedOdds = combinedOdds.mul(sel.odds); } else if (sel.result === 'HALF_WIN') { combinedOdds = combinedOdds.mul(new Decimal(sel.odds).add(1).div(2)); } else if (sel.result === 'HALF_LOSE') { combinedOdds = combinedOdds.mul(0.5); } // PUSH/VOID => odds 1.00 } const allPush = selections.every( (sel) => sel.result === 'PUSH' || sel.result === 'VOID', ); if (allPush) { return { betResult: 'PUSH', payout: s, effectiveOdds: new Decimal(1) }; } return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds }; } export { FT_CORRECT_SCORE_TEMPLATE, HT_CORRECT_SCORE_TEMPLATE, isQuarterHandicapOrTotal, } from '@thebet365/shared';