UNPKG

chat-bet-parse

Version:

TypeScript package for parsing sports betting contract text into structured data types compatible with SQL Server stored procedures

1,385 lines (1,382 loc) 124 kB
import { isFill, isHandicapLine, isHandicapML, isOrder, isParlay, isPropOU, isPropYN, isRoundRobin, isSeries, isStraight, isTotalPoints, isTotalPointsContestant, isWritein, knownLeagues, knownSports, leagueSportMap } from "./chunk-VWMES7GD.mjs"; // src/errors/index.ts var ChatBetParseError = class extends Error { constructor(message, rawInput, position) { super(message.trimEnd()); this.name = "ChatBetParseError"; this.rawInput = rawInput; this.position = position; } }; var InvalidChatFormatError = class extends ChatBetParseError { constructor(rawInput, reason) { super(`Invalid chat format: ${reason}. Input: "${rawInput}"`, rawInput); this.name = "InvalidChatFormatError"; } }; var InvalidContractTypeError = class extends ChatBetParseError { constructor(rawInput, contractPortion) { super( `Unable to determine contract type from: "${contractPortion}". Input: "${rawInput}"`, rawInput ); this.name = "InvalidContractTypeError"; } }; var InvalidPriceFormatError = class extends ChatBetParseError { constructor(rawInput, priceStr) { super( `Invalid USA price format: "${priceStr}". Expected format: +150, -110, -115.5, ev, or even. Input: "${rawInput}"`, rawInput ); this.name = "InvalidPriceFormatError"; } }; var InvalidSizeFormatError = class extends ChatBetParseError { constructor(rawInput, sizeStr, expectedFormat) { super( `Invalid size format: "${sizeStr}". Expected: ${expectedFormat}. Input: "${rawInput}"`, rawInput ); this.name = "InvalidSizeFormatError"; } }; var InvalidLineValueError = class extends ChatBetParseError { constructor(rawInput, line) { super( `Invalid line value: ${line}. Line must be divisible by 0.5. Input: "${rawInput}"`, rawInput ); this.name = "InvalidLineValueError"; } }; var InvalidTeamFormatError = class extends ChatBetParseError { constructor(rawInput, teamStr, reason) { super(`Invalid team format: "${teamStr}". ${reason}. Input: "${rawInput}"`, rawInput); this.name = "InvalidTeamFormatError"; } }; var InvalidPeriodFormatError = class extends ChatBetParseError { constructor(rawInput, periodStr) { super( `Invalid period format: "${periodStr}". Expected formats: 1st inning, F5, 1H, Q1, etc. Input: "${rawInput}"`, rawInput ); this.name = "InvalidPeriodFormatError"; } }; var InvalidGameNumberError = class extends ChatBetParseError { constructor(rawInput, gameStr) { super( `Invalid game number format: "${gameStr}". Expected formats: G2, GM1, #2, etc. Input: "${rawInput}"`, rawInput ); this.name = "InvalidGameNumberError"; } }; var InvalidRotationNumberError = class extends ChatBetParseError { constructor(rawInput, rotationStr) { super( `Invalid rotation number: "${rotationStr}". Must be a positive integer. Input: "${rawInput}"`, rawInput ); this.name = "InvalidRotationNumberError"; } }; var InvalidPropFormatError = class extends ChatBetParseError { constructor(rawInput, propStr, availableProps) { super( `Invalid prop format: "${propStr}". Available props: ${availableProps.join(", ")}. Input: "${rawInput}"`, rawInput ); this.name = "InvalidPropFormatError"; } }; var InvalidSeriesLengthError = class extends ChatBetParseError { constructor(rawInput, lengthStr) { super( `Invalid series length: "${lengthStr}". Must be a positive integer. Input: "${rawInput}"`, rawInput ); this.name = "InvalidSeriesLengthError"; } }; var MissingSizeForFillError = class extends ChatBetParseError { constructor(rawInput) { super(`Fill (YG/YGP/YGRR) messages require a size`, rawInput); this.name = "MissingSizeForFillError"; } }; var UnrecognizedChatPrefixError = class extends ChatBetParseError { constructor(rawInput, prefix) { super( `Unrecognized chat prefix: "${prefix}". Chat must be either a chat order (start with "IW" for "i want") or a chat fill (start with "YG", for "you got"). Input: "${rawInput}"`, rawInput ); this.name = "UnrecognizedChatPrefixError"; } }; var AmbiguousContractError = class extends ChatBetParseError { constructor(rawInput, possibleTypes) { super( `Ambiguous contract type. Could be: ${possibleTypes.join(", ")}. Please be more specific. Input: "${rawInput}"`, rawInput ); this.name = "AmbiguousContractError"; } }; var InvalidDateError = class extends ChatBetParseError { constructor(rawInput, dateStr, reason) { super( `Invalid date: "${dateStr}". ${reason}. Expected formats: YYYY-MM-DD, MM/DD/YYYY, YYYY/MM/DD, MM-DD-YYYY, or equivalents without year. Input: "${rawInput}"`, rawInput ); this.name = "InvalidDateError"; } }; var InvalidWriteinDateError = class extends ChatBetParseError { constructor(rawInput, dateStr, reason) { super( `Invalid writein date: "${dateStr}". ${reason}. Expected formats: YYYY-MM-DD, MM/DD/YYYY, YYYY/MM/DD, MM-DD-YYYY, or equivalents without year. Input: "${rawInput}"`, rawInput ); this.name = "InvalidWriteinDateError"; } }; var InvalidWriteinDescriptionError = class extends ChatBetParseError { constructor(rawInput, description, reason) { super( `Invalid writein description: "${description}". ${reason}. Input: "${rawInput}"`, rawInput ); this.name = "InvalidWriteinDescriptionError"; } }; var InvalidWriteinFormatError = class extends ChatBetParseError { constructor(rawInput, reason) { super( `Invalid writein format: ${reason}. Expected format: "IW/YG writein DATE DESCRIPTION [@ price] [= size]". Input: "${rawInput}"`, rawInput ); this.name = "InvalidWriteinFormatError"; } }; var InvalidKeywordSyntaxError = class extends ChatBetParseError { constructor(rawInput, _keyword, message) { super(message, rawInput); this.name = "InvalidKeywordSyntaxError"; } }; var InvalidKeywordValueError = class extends ChatBetParseError { constructor(rawInput, _keyword, _value, message) { super(message, rawInput); this.name = "InvalidKeywordValueError"; } }; var UnknownKeywordError = class extends ChatBetParseError { constructor(rawInput, keyword) { super(`Unknown keyword: ${keyword}`, rawInput); this.name = "UnknownKeywordError"; } }; var InvalidParlayStructureError = class extends ChatBetParseError { constructor(rawInput, message) { super(message, rawInput); this.name = "InvalidParlayStructureError"; } }; var InvalidParlayLegError = class extends ChatBetParseError { constructor(rawInput, legNumber, message) { super(`Leg ${legNumber}: ${message}`, rawInput); this.name = "InvalidParlayLegError"; } }; var InvalidParlayToWinError = class extends ChatBetParseError { constructor(rawInput, message) { super(message, rawInput); this.name = "InvalidParlayToWinError"; } }; var MissingNcrNotationError = class extends ChatBetParseError { constructor(rawInput, message = "Round robin requires nCr notation") { super(message, rawInput); this.name = "MissingNcrNotationError"; } }; var LegCountMismatchError = class extends ChatBetParseError { constructor(rawInput, expected, actual) { super(`Expected ${expected} legs from nCr notation, but found ${actual}`, rawInput); this.name = "LegCountMismatchError"; } }; var MissingRiskTypeError = class extends ChatBetParseError { constructor(rawInput) { super('Round robin requires risk type: "per" or "total"', rawInput); this.name = "MissingRiskTypeError"; } }; var InvalidRiskTypeError = class extends ChatBetParseError { constructor(rawInput, value) { super(`Invalid risk type: must be "per" or "total", got "${value}"`, rawInput); this.name = "InvalidRiskTypeError"; } }; var InvalidRoundRobinLegError = class extends ChatBetParseError { constructor(rawInput, legNumber, message) { super(`Leg ${legNumber}: ${message}`, rawInput); this.name = "InvalidRoundRobinLegError"; } }; var InvalidRoundRobinToWinError = class extends ChatBetParseError { constructor(rawInput, message) { super(message, rawInput); this.name = "InvalidRoundRobinToWinError"; } }; function createPositionError(ErrorClass, rawInput, position, ...args) { const error = new ErrorClass(rawInput, ...args); error.position = position; return error; } // src/parsers/ncr.ts var InvalidNcrNotationError = class extends ChatBetParseError { constructor(rawInput, message) { super(message, rawInput); this.name = "InvalidNcrNotationError"; } }; function parseNcrNotation(notation, rawInput) { const trimmed = notation.trim(); if (/[cC].*,/.test(trimmed)) { throw new InvalidNcrNotationError(rawInput, "Comma-separated parlay sizes not supported"); } const match = trimmed.match(/^(-?[\d.]+|[A-Za-z]+)[cC](-?[\d.]+|[A-Za-z]+|[^-\s]+)(-+)?$/i); if (!match) { throw new InvalidNcrNotationError(rawInput, "Invalid nCr notation format"); } const totalLegsStr = match[1]; const parlaySizeStr = match[2]; const minusModifier = match[3] || ""; if (minusModifier.length > 1) { throw new InvalidNcrNotationError(rawInput, "Invalid at-most modifier"); } const isAtMost = minusModifier === "-"; if (!/^-?\d+(?:\.\d+)?$/.test(totalLegsStr)) { throw new InvalidNcrNotationError(rawInput, "Total legs must be a number"); } if (!/^-?\d+(?:\.\d+)?$/.test(parlaySizeStr)) { throw new InvalidNcrNotationError(rawInput, "Parlay size must be a number"); } if (totalLegsStr.includes(".")) { throw new InvalidNcrNotationError(rawInput, "Total legs must be an integer"); } if (parlaySizeStr.includes(".")) { throw new InvalidNcrNotationError(rawInput, "Parlay size must be an integer"); } const totalLegs = parseInt(totalLegsStr, 10); const parlaySize = parseInt(parlaySizeStr, 10); if (totalLegs < 0) { throw new InvalidNcrNotationError(rawInput, "Total legs must be positive"); } if (parlaySize < 0) { throw new InvalidNcrNotationError(rawInput, "Parlay size must be positive"); } if (totalLegs < 3) { throw new InvalidNcrNotationError(rawInput, "Total legs must be at least 3"); } if (parlaySize < 2) { throw new InvalidNcrNotationError(rawInput, "Parlay size must be at least 2"); } if (parlaySize >= totalLegs) { throw new InvalidNcrNotationError(rawInput, "Parlay size must be less than total legs"); } return { totalLegs, parlaySize, isAtMost }; } // src/parsers/utils.ts function parsePrice(priceStr, rawInput) { const cleaned = priceStr.trim(); if (cleaned.toLowerCase() === "ev" || cleaned.toLowerCase() === "even") { return 100; } const match = cleaned.match(/^([+-])(\d+(?:\.\d+)?)$/); if (!match) { throw new InvalidPriceFormatError(rawInput, priceStr); } const sign = match[1]; const value = parseFloat(match[2]); return sign === "+" ? value : -value; } function calculateRiskAndToWin(price, size) { if (price >= 100) { const risk = size; const toWin = Math.round(size * price / 100 * 100) / 100; return { risk, toWin }; } else if (price <= -100) { const absOdds = Math.abs(price); const risk = Math.round(size * absOdds / 100 * 100) / 100; const toWin = size; return { risk, toWin }; } else { throw new Error(`Invalid price ${price}: must be >= 100 or <= -100`); } } function calculateToWinFromRisk(price, risk) { if (price >= 100) { return Math.round(risk * price / 100 * 100) / 100; } else if (price <= -100) { const absOdds = Math.abs(price); return Math.round(risk / (absOdds / 100) * 100) / 100; } else { throw new Error(`Invalid price ${price}: must be >= 100 or <= -100`); } } function calculateRiskFromToWin(price, toWin) { if (price >= 100) { return Math.round(toWin / (price / 100) * 100) / 100; } else if (price <= -100) { const absOdds = Math.abs(price); return Math.round(toWin * (absOdds / 100) * 100) / 100; } else { throw new Error(`Invalid price ${price}: must be >= 100 or <= -100`); } } function validateCommaPlacement(str) { if (!str.includes(",")) { return true; } const numPart = str.startsWith("$") ? str.substring(1) : str; const withoutK = numPart.toLowerCase().endsWith("k") ? numPart.slice(0, -1) : numPart; const commaPattern = /^\d{1,3}(,\d{3})+$/; return commaPattern.test(withoutK); } function parseSize(sizeStr, rawInput, config) { const cleaned = sizeStr.trim(); if (!validateCommaPlacement(cleaned)) { throw new InvalidSizeFormatError( rawInput, sizeStr, `Invalid comma placement in number: "${sizeStr}". Use American thousands format (e.g., 1,234 or 12,345)` ); } const withoutCommas = cleaned.replace(/,/g, ""); if (withoutCommas.startsWith("$") && withoutCommas.toLowerCase().endsWith("k")) { const numPart = withoutCommas.substring(1, withoutCommas.length - 1); const value2 = parseFloat(numPart); if (isNaN(value2) || value2 < 0) { throw new InvalidSizeFormatError( rawInput, sizeStr, "positive dollar amount with k like $11k or $2.5k" ); } return { value: value2 * 1e3, format: "k_notation" }; } if (withoutCommas.startsWith("$")) { const value2 = parseFloat(withoutCommas.substring(1)); if (isNaN(value2) || value2 < 0) { throw new InvalidSizeFormatError( rawInput, sizeStr, "positive dollar amount like $100 or $2.50" ); } return { value: value2, format: "dollar" }; } if (withoutCommas.toLowerCase().endsWith("k")) { const value2 = parseFloat(withoutCommas.slice(0, -1)); if (isNaN(value2) || value2 < 0) { throw new InvalidSizeFormatError(rawInput, sizeStr, "positive number with k like 4k or 2.5k"); } return { value: value2 * 1e3, format: "k_notation" }; } const value = parseFloat(withoutCommas); if (isNaN(value) || value < 0) { const errorHint = config.interpretation === "decimal_thousands" ? "positive number like 100 (=$100) or 2.5 (=$2500)" : "positive decimal number like 2.0 or 0.50"; throw new InvalidSizeFormatError(rawInput, sizeStr, errorHint); } if (config.interpretation === "decimal_thousands" && withoutCommas.includes(".")) { return { value: value * 1e3, format: "decimal_thousands" }; } else if (config.interpretation === "decimal_thousands") { return { value, format: "plain_number" }; } else { return { value, format: "unit" }; } } function parseOrderSize(sizeStr, rawInput) { return parseSize(sizeStr, rawInput, { interpretation: "unit" }); } function parseFillSize(sizeStr, rawInput) { return parseSize(sizeStr, rawInput, { interpretation: "decimal_thousands" }); } function parseStraightSize(sizeText, rawInput, interpretation) { if (!sizeText.startsWith("=")) { throw new InvalidSizeFormatError(rawInput, sizeText, "Size must start with ="); } const text = sizeText.slice(1).trim(); const twMatch = text.match(/^([$\d.,]+k?)\s+(?:tw|to\s+win)\s+([$\d.,]+k?)$/i); if (twMatch) { const riskParsed = parseSize(twMatch[1], rawInput, { interpretation }); const toWinParsed = parseSize(twMatch[2], rawInput, { interpretation }); return { risk: riskParsed.value, toWin: toWinParsed.value }; } const tpMatch = text.match(/^([$\d.,]+k?)\s+(?:tp|to\s+pay)\s+([$\d.,]+k?)$/i); if (tpMatch) { const riskParsed = parseSize(tpMatch[1], rawInput, { interpretation }); const toPayParsed = parseSize(tpMatch[2], rawInput, { interpretation }); const toWin = toPayParsed.value - riskParsed.value; if (toWin < 0) { throw new InvalidSizeFormatError( rawInput, sizeText, "To-pay amount must be greater than risk amount" ); } return { risk: riskParsed.value, toWin: Math.round(toWin * 100) / 100 }; } const riskMatch = text.match(/^risk\s+([$\d.,]+k?)$/i); if (riskMatch) { const riskParsed = parseSize(riskMatch[1], rawInput, { interpretation }); return { risk: riskParsed.value }; } const toWinMatch = text.match(/^towin\s+([$\d.,]+k?)$/i); if (toWinMatch) { const toWinParsed = parseSize(toWinMatch[1], rawInput, { interpretation }); return { toWin: toWinParsed.value }; } const sizeMatch = text.match(/^([$\d.,]+k?)$/); if (sizeMatch) { const sizeParsed = parseSize(sizeMatch[1], rawInput, { interpretation }); return { size: sizeParsed.value }; } throw new InvalidSizeFormatError( rawInput, sizeText, 'Format: "= $100", "= $110 tw $100", "= $120 tp $220", "= risk $110", or "= towin $150"' ); } function parseLine(lineStr, rawInput) { const value = parseFloat(lineStr); if (isNaN(value)) { throw new InvalidLineValueError(rawInput, value); } if (value * 2 % 1 !== 0) { throw new InvalidLineValueError(rawInput, value); } return value; } function parsePeriod(periodStr, rawInput) { const cleaned = periodStr.toLowerCase().trim(); if (!cleaned || cleaned === "fg" || cleaned === "full game") { return { PeriodTypeCode: "M", PeriodNumber: 0 }; } if (cleaned === "f5" || cleaned === "h1" || cleaned === "1h" || cleaned === "first half" || cleaned === "1st half" || cleaned === "first h" || cleaned === "1st h" || cleaned === "first five" || cleaned === "1st five" || cleaned === "first 5" || cleaned === "1st 5") { return { PeriodTypeCode: "H", PeriodNumber: 1 }; } if (cleaned === "f3") { return { PeriodTypeCode: "H", PeriodNumber: 13 }; } if (cleaned === "f7") { return { PeriodTypeCode: "H", PeriodNumber: 17 }; } if (cleaned === "h2" || cleaned === "2h" || cleaned === "second half" || cleaned === "2nd half" || cleaned === "second h" || cleaned === "2nd h") { return { PeriodTypeCode: "H", PeriodNumber: 2 }; } const quarterMatch = cleaned.match(/^(?:(\d+)(?:st|nd|rd|th)?\s*quarter?|q(\d+)|(\d+)q)$/); if (quarterMatch) { const qNum = parseInt(quarterMatch[1] || quarterMatch[2] || quarterMatch[3]); if (qNum >= 1 && qNum <= 4) { return { PeriodTypeCode: "Q", PeriodNumber: qNum }; } } const inningMatch = cleaned.match(/^(?:(\d+)(?:st|nd|rd|th)?\s*inning?|i(\d+)|(\d+)i)$/); if (inningMatch) { const iNum = parseInt(inningMatch[1] || inningMatch[2] || inningMatch[3]); if (iNum >= 1 && iNum <= 15) { return { PeriodTypeCode: "I", PeriodNumber: iNum }; } } const periodMatch = cleaned.match(/^(?:(\d+)(?:st|nd|rd|th)?\s*period?|p(\d+)|(\d+)p)$/); if (periodMatch) { const pNum = parseInt(periodMatch[1] || periodMatch[2] || periodMatch[3]); if (pNum >= 1 && pNum <= 4) { return { PeriodTypeCode: "P", PeriodNumber: pNum }; } } throw new InvalidPeriodFormatError(rawInput, periodStr); } function parseGameNumber(gameStr, rawInput) { const cleaned = gameStr.toLowerCase().trim(); const match = cleaned.match(/^(?:(?:game|gm|g)\s*(\d+)|#\s*(\d+))$/); if (!match) { throw new InvalidGameNumberError(rawInput, gameStr); } const gameNum = parseInt(match[1] || match[2]); if (gameNum < 1 || gameNum > 10) { throw new InvalidGameNumberError(rawInput, gameStr); } return gameNum; } function parseRotationNumber(rotationStr, rawInput) { const value = parseInt(rotationStr.trim()); if (isNaN(value) || value < 1 || value > 9999) { throw new InvalidRotationNumberError(rawInput, rotationStr); } return value; } function parseTeam(teamStr, rawInput) { const cleaned = teamStr.trim(); if (!cleaned) { throw new InvalidTeamFormatError(rawInput, teamStr, "Team name cannot be empty"); } if (!/^[a-zA-Z0-9\s&\-.']+$/.test(cleaned)) { throw new InvalidTeamFormatError(rawInput, teamStr, "Team name contains invalid characters"); } if (cleaned.length > 50) { throw new InvalidTeamFormatError(rawInput, teamStr, "Team name too long (max 50 characters)"); } return cleaned; } function detectContestantType(contestant) { if (/^[A-Z]\.\s+[A-Za-z]+/.test(contestant)) { return "Individual"; } return void 0; } function parseTeams(teamsStr, rawInput) { const parts = teamsStr.split("/"); if (parts.length === 1) { return { team1: parseTeam(parts[0], rawInput) }; } else if (parts.length === 2) { const team1 = parseTeam(parts[0], rawInput); const team2 = parseTeam(parts[1], rawInput); if (team1 === team2) { throw new InvalidTeamFormatError( rawInput, teamsStr, `Team1 and Team2 cannot be the same: "${team1}"` ); } return { team1, team2 }; } else { throw new InvalidTeamFormatError(rawInput, teamsStr, 'Too many "/" separators'); } } function inferSportAndLeague(rotationNumber, explicitLeague, explicitSport) { let sport = explicitSport; let league = explicitLeague; if (explicitLeague && explicitSport && leagueSportMap[explicitLeague] !== explicitSport) { throw new Error("Conflicting explicit league and sport specifications"); } if (explicitLeague && !sport) { sport = leagueSportMap[explicitLeague]; } if (league === "FCS") { league = "CFB"; } if (league === "CBB") { league = "CBK"; } if ((!sport || !league) && rotationNumber) { if (rotationNumber >= 100 && rotationNumber < 499) { const inferredSport = "Football"; if (!sport) sport = inferredSport; return { sport: "Football" }; } if (rotationNumber >= 500 && rotationNumber < 800) { const inferredSport = "Basketball"; if (!sport) sport = inferredSport; return { sport: "Basketball" }; } if (rotationNumber >= 800 && rotationNumber < 900 || rotationNumber >= 9900 && rotationNumber < 1e4) { const inferredSport = "Baseball"; if (!sport) sport = inferredSport; return { sport: "Baseball" }; } } return { sport, league }; } function matchesIgnoreCase(text, pattern) { return text.toLowerCase().includes(pattern.toLowerCase()); } function parsePlayerWithTeam(text) { const trimmed = text.trim(); const match = trimmed.match(/^(.+?)\s*\(([A-Z]{2,4})\)$/); if (match) { return { player: match[1].trim(), team: match[2].trim() }; } return { player: trimmed }; } function parseOverUnder(ouStr, rawInput) { const match = ouStr.toLowerCase().match(/^([ou])(.+)$/); if (!match) { throw new InvalidLineValueError(rawInput, parseFloat(ouStr)); } const isOver = match[1] === "o"; const lineAndPrice = match[2]; const attachedPriceMatch = lineAndPrice.match(/^(\d+(?:\.\d+)?)([+-]\d+(?:\.\d+)?)$/); if (attachedPriceMatch) { const line2 = parseLine(attachedPriceMatch[1], rawInput); const priceStr = attachedPriceMatch[2]; const attachedPrice = parsePrice(priceStr, rawInput); return { isOver, line: line2, attachedPrice }; } const line = parseLine(lineAndPrice, rawInput); return { isOver, line }; } var PROP_TYPE_MAP = { // ============================================================================ // BASEBALL PROPS // ============================================================================ // Baseball - Hitting (Individual) hits: { standardName: "Hits", category: "PropOU", contestantType: "Individual" }, "total bases": { standardName: "TotalBases", category: "PropOU", contestantType: "Individual" }, singles: { standardName: "Singles", category: "PropOU", contestantType: "Individual" }, doubles: { standardName: "Doubles", category: "PropOU", contestantType: "Individual" }, triples: { standardName: "Triples", category: "PropOU", contestantType: "Individual" }, "home runs": { standardName: "HomeRuns", category: "PropOU", contestantType: "Individual" }, hrs: { standardName: "HomeRuns", category: "PropOU", contestantType: "Individual" }, "runs scored": { standardName: "RunsScored", category: "PropOU", contestantType: "Individual" }, // Note: "runs" is ambiguous (appears in game totals like "o0.5 runs"), so it's excluded rbi: { standardName: "RBI", category: "PropOU", contestantType: "Individual" }, rbis: { standardName: "RBI", category: "PropOU", contestantType: "Individual" }, walks: { standardName: "Walks", category: "PropOU", contestantType: "Individual" }, bb: { standardName: "Walks", category: "PropOU", contestantType: "Individual" }, "stolen bases": { standardName: "StolenBases", category: "PropOU", contestantType: "Individual" }, sb: { standardName: "StolenBases", category: "PropOU", contestantType: "Individual" }, // Baseball - Pitching (Individual) ks: { standardName: "Ks", category: "PropOU", contestantType: "Individual" }, strikeouts: { standardName: "Ks", category: "PropOU", contestantType: "Individual" }, "earned runs": { standardName: "EarnedRuns", category: "PropOU", contestantType: "Individual" }, er: { standardName: "EarnedRuns", category: "PropOU", contestantType: "Individual" }, "hits allowed": { standardName: "HitsAllowed", category: "PropOU", contestantType: "Individual" }, "walks allowed": { standardName: "WalksAllowed", category: "PropOU", contestantType: "Individual" }, "innings pitched": { standardName: "InningsPitched", category: "PropOU", contestantType: "Individual" }, ip: { standardName: "InningsPitched", category: "PropOU", contestantType: "Individual" }, "pitches thrown": { standardName: "PitchesThrown", category: "PropOU", contestantType: "Individual" }, pitches: { standardName: "PitchesThrown", category: "PropOU", contestantType: "Individual" }, "outs recorded": { standardName: "OutsRecorded", category: "PropOU", contestantType: "Individual" }, // Baseball - Yes/No (Individual) "to record a hit": { standardName: "ToRecordAHit", category: "PropYN", contestantType: "Individual" }, "to get a hit": { standardName: "ToRecordAHit", category: "PropYN", contestantType: "Individual" }, "to hit a home run": { standardName: "ToHitHomeRun", category: "PropYN", contestantType: "Individual" }, "to hit a hr": { standardName: "ToHitHomeRun", category: "PropYN", contestantType: "Individual" }, "to steal a base": { standardName: "ToStealBase", category: "PropYN", contestantType: "Individual" }, "to record a win": { standardName: "ToRecordWin", category: "PropYN", contestantType: "Individual" }, // ============================================================================ // FOOTBALL PROPS // ============================================================================ // Football - Passing (Individual) "passing yards": { standardName: "PassingYards", category: "PropOU", contestantType: "Individual" }, passingyards: { standardName: "PassingYards", category: "PropOU", contestantType: "Individual" }, "pass yards": { standardName: "PassingYards", category: "PropOU", contestantType: "Individual" }, "pass yds": { standardName: "PassingYards", category: "PropOU", contestantType: "Individual" }, "passing tds": { standardName: "PassingTDs", category: "PropOU", contestantType: "Individual" }, "pass tds": { standardName: "PassingTDs", category: "PropOU", contestantType: "Individual" }, completions: { standardName: "Completions", category: "PropOU", contestantType: "Individual" }, "pass completions": { standardName: "Completions", category: "PropOU", contestantType: "Individual" }, "pass attempts": { standardName: "PassAttempts", category: "PropOU", contestantType: "Individual" }, interceptions: { standardName: "Interceptions", category: "PropOU", contestantType: "Individual" }, ints: { standardName: "Interceptions", category: "PropOU", contestantType: "Individual" }, "longest completion": { standardName: "LongestCompletion", category: "PropOU", contestantType: "Individual" }, // Football - Rushing (Individual) "rushing yards": { standardName: "RushingYards", category: "PropOU", contestantType: "Individual" }, "rush yards": { standardName: "RushingYards", category: "PropOU", contestantType: "Individual" }, "rush yds": { standardName: "RushingYards", category: "PropOU", contestantType: "Individual" }, "rushing tds": { standardName: "RushingTDs", category: "PropOU", contestantType: "Individual" }, "rush tds": { standardName: "RushingTDs", category: "PropOU", contestantType: "Individual" }, "rush attempts": { standardName: "RushAttempts", category: "PropOU", contestantType: "Individual" }, carries: { standardName: "RushAttempts", category: "PropOU", contestantType: "Individual" }, "longest rush": { standardName: "LongestRush", category: "PropOU", contestantType: "Individual" }, // Football - Receiving (Individual) "receiving yards": { standardName: "ReceivingYards", category: "PropOU", contestantType: "Individual" }, receivingyards: { standardName: "ReceivingYards", category: "PropOU", contestantType: "Individual" }, "rec yards": { standardName: "ReceivingYards", category: "PropOU", contestantType: "Individual" }, "rec yds": { standardName: "ReceivingYards", category: "PropOU", contestantType: "Individual" }, "receiving tds": { standardName: "ReceivingTDs", category: "PropOU", contestantType: "Individual" }, "rec tds": { standardName: "ReceivingTDs", category: "PropOU", contestantType: "Individual" }, receptions: { standardName: "Receptions", category: "PropOU", contestantType: "Individual" }, catches: { standardName: "Receptions", category: "PropOU", contestantType: "Individual" }, "longest reception": { standardName: "LongestReception", category: "PropOU", contestantType: "Individual" }, // Football - Defense/Special Teams (Individual) tackles: { standardName: "Tackles", category: "PropOU", contestantType: "Individual" }, sacks: { standardName: "Sacks", category: "PropOU", contestantType: "Individual" }, "tackles and assists": { standardName: "TacklesAndAssists", category: "PropOU", contestantType: "Individual" }, "field goals made": { standardName: "FieldGoalsMade", category: "PropOU", contestantType: "Individual" }, "fgs made": { standardName: "FieldGoalsMade", category: "PropOU", contestantType: "Individual" }, "extra points": { standardName: "ExtraPoints", category: "PropOU", contestantType: "Individual" }, xp: { standardName: "ExtraPoints", category: "PropOU", contestantType: "Individual" }, "kicking points": { standardName: "KickingPoints", category: "PropOU", contestantType: "Individual" }, // Football - Yes/No (Individual) "anytime td": { standardName: "AnytimeTD", category: "PropYN", contestantType: "Individual" }, "anytime touchdown": { standardName: "AnytimeTD", category: "PropYN", contestantType: "Individual" }, "to score a touchdown": { standardName: "AnytimeTD", category: "PropYN", contestantType: "Individual" }, "first td": { standardName: "FirstTD", category: "PropYN", contestantType: "Individual" }, "first touchdown": { standardName: "FirstTD", category: "PropYN", contestantType: "Individual" }, "last td": { standardName: "LastTD", category: "PropYN", contestantType: "Individual" }, "last touchdown": { standardName: "LastTD", category: "PropYN", contestantType: "Individual" }, "2+ tds": { standardName: "TwoOrMoreTDs", category: "PropYN", contestantType: "Individual" }, "3+ tds": { standardName: "ThreeOrMoreTDs", category: "PropYN", contestantType: "Individual" }, // Football - Team Stats (TeamLeague) "team passing yards": { standardName: "PassingYards", category: "PropOU", contestantType: "TeamLeague" }, "team rushing yards": { standardName: "TeamRushingYards", category: "PropOU", contestantType: "TeamLeague" }, "team sacks": { standardName: "TeamSacks", category: "PropOU", contestantType: "TeamLeague" }, // ============================================================================ // BASKETBALL PROPS // ============================================================================ // Basketball - Scoring (Individual) points: { standardName: "Points", category: "PropOU", contestantType: "Individual" }, pts: { standardName: "Points", category: "PropOU", contestantType: "Individual" }, // Basketball - Rebounding (Individual) rebounds: { standardName: "Rebounds", category: "PropOU", contestantType: "Individual" }, rebs: { standardName: "Rebounds", category: "PropOU", contestantType: "Individual" }, "total rebounds": { standardName: "Rebounds", category: "PropOU", contestantType: "Individual" }, // Basketball - Assists (Individual) assists: { standardName: "Assists", category: "PropOU", contestantType: "Individual" }, ast: { standardName: "Assists", category: "PropOU", contestantType: "Individual" }, asts: { standardName: "Assists", category: "PropOU", contestantType: "Individual" }, // Basketball - Defense (Individual) steals: { standardName: "Steals", category: "PropOU", contestantType: "Individual" }, stls: { standardName: "Steals", category: "PropOU", contestantType: "Individual" }, blocks: { standardName: "Blocks", category: "PropOU", contestantType: "Individual" }, blks: { standardName: "Blocks", category: "PropOU", contestantType: "Individual" }, "steals and blocks": { standardName: "StealsAndBlocks", category: "PropOU", contestantType: "Individual" }, // Basketball - Other (Individual) turnovers: { standardName: "Turnovers", category: "PropOU", contestantType: "Individual" }, tos: { standardName: "Turnovers", category: "PropOU", contestantType: "Individual" }, "three pointers made": { standardName: "ThreePointersMade", category: "PropOU", contestantType: "Individual" }, threes: { standardName: "ThreePointersMade", category: "PropOU", contestantType: "Individual" }, "3pm": { standardName: "ThreePointersMade", category: "PropOU", contestantType: "Individual" }, "3-pointers": { standardName: "ThreePointersMade", category: "PropOU", contestantType: "Individual" }, "3pt shots made": { standardName: "ThreePointersMade", category: "PropOU", contestantType: "Individual" }, "free throws made": { standardName: "FreeThrowsMade", category: "PropOU", contestantType: "Individual" }, ftm: { standardName: "FreeThrowsMade", category: "PropOU", contestantType: "Individual" }, // Basketball - Combo Stats (Individual) "points rebounds assists": { standardName: "PRA", category: "PropOU", contestantType: "Individual" }, pra: { standardName: "PRA", category: "PropOU", contestantType: "Individual" }, "pts+reb+ast": { standardName: "PRA", category: "PropOU", contestantType: "Individual" }, "pts+rebs+asts": { standardName: "PRA", category: "PropOU", contestantType: "Individual" }, "points and rebounds": { standardName: "PR", category: "PropOU", contestantType: "Individual" }, "pts+rebs": { standardName: "PR", category: "PropOU", contestantType: "Individual" }, "points and assists": { standardName: "PA", category: "PropOU", contestantType: "Individual" }, "pts+ast": { standardName: "PA", category: "PropOU", contestantType: "Individual" }, "rebounds and assists": { standardName: "RA", category: "PropOU", contestantType: "Individual" }, "rebs+ast": { standardName: "RA", category: "PropOU", contestantType: "Individual" }, // Basketball - Yes/No (Individual) "double double": { standardName: "DoubleDouble", category: "PropYN", contestantType: "Individual" }, "to record a double double": { standardName: "DoubleDouble", category: "PropYN", contestantType: "Individual" }, "triple double": { standardName: "TripleDouble", category: "PropYN", contestantType: "Individual" }, "to record a triple double": { standardName: "TripleDouble", category: "PropYN", contestantType: "Individual" }, // ============================================================================ // CROSS-SPORT TEAM PROPS (TeamLeague) // ============================================================================ "first team to score": { standardName: "FirstToScore", category: "PropYN", contestantType: "TeamLeague" }, "1st team to score": { standardName: "FirstToScore", category: "PropYN", contestantType: "TeamLeague" }, "first to score": { standardName: "FirstToScore", category: "PropYN", contestantType: "TeamLeague" }, "to score first": { standardName: "FirstToScore", category: "PropYN", contestantType: "TeamLeague" }, "last team to score": { standardName: "LastToScore", category: "PropYN", contestantType: "TeamLeague" }, "last to score": { standardName: "LastToScore", category: "PropYN", contestantType: "TeamLeague" }, "to score last": { standardName: "LastToScore", category: "PropYN", contestantType: "TeamLeague" } }; function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function detectPropType(propText) { const cleanText = propText.toLowerCase().trim(); const sortedKeywords = Object.entries(PROP_TYPE_MAP).sort(([a], [b]) => b.length - a.length); for (const [keyword, info] of sortedKeywords) { const escapedKeyword = escapeRegex(keyword).replace(/\s+/g, "\\s+"); const regex = new RegExp(`\\b${escapedKeyword}\\b`); if (regex.test(cleanText)) { return info; } } return null; } function extractContestantAndProp(text) { const cleanText = text.trim(); const sortedKeywords = Object.entries(PROP_TYPE_MAP).sort(([a], [b]) => b.length - a.length); for (const [keyword] of sortedKeywords) { const escapedKeyword = escapeRegex(keyword).replace(/\s+/g, "\\s+"); const regex = new RegExp(`\\b(${escapedKeyword})\\b`, "i"); const match = cleanText.match(regex); if (match) { const contestant = cleanText.substring(0, match.index).trim(); const propText = match[1].toLowerCase(); if (contestant) { return { contestant, propText }; } } } return null; } function validatePropFormat(propText, hasLine, rawInput) { const propInfo = detectPropType(propText); if (!propInfo) { throw new InvalidContractTypeError(rawInput, `Unsupported prop type: ${propText}`); } if (propInfo.category === "PropOU" && !hasLine) { throw new InvalidContractTypeError( rawInput, `${propInfo.standardName} props require an over/under line (e.g., "o12.5")` ); } if (propInfo.category === "PropYN" && hasLine) { throw new InvalidContractTypeError( rawInput, `${propInfo.standardName} props cannot have a line - they are yes/no bets only` ); } } function parseWriteinDate(dateString, rawInput, isWritein2 = true, referenceDate) { const cleaned = dateString.trim(); const ErrorClass = isWritein2 ? InvalidWriteinDateError : InvalidDateError; if (!cleaned) { throw new ErrorClass(rawInput, dateString, "Date cannot be empty"); } let parsedDate = null; const refDate = referenceDate || /* @__PURE__ */ new Date(); const currentYear = refDate.getUTCFullYear(); const today = new Date( Date.UTC(refDate.getUTCFullYear(), refDate.getUTCMonth(), refDate.getUTCDate()) ); const patterns = [ // Full date patterns with 4-digit year /^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/, // YYYY/MM/DD or YYYY-MM-DD /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/, // MM/DD/YYYY or MM-DD-YYYY // Full date patterns with 2-digit year /^(\d{1,2})[/-](\d{1,2})[/-](\d{2})$/, // MM/DD/YY or MM-DD-YY // Date patterns without year /^(\d{1,2})[/-](\d{1,2})$/ // MM/DD or MM-DD ]; for (const pattern of patterns) { const match = cleaned.match(pattern); if (match) { let year, month, day; if (match.length === 4) { if (pattern.source.startsWith("^(\\d{4})")) { year = parseInt(match[1]); month = parseInt(match[2]); day = parseInt(match[3]); } else if (pattern.source.includes("(\\d{2})$")) { month = parseInt(match[1]); day = parseInt(match[2]); const twoDigitYear = parseInt(match[3]); year = 2e3 + twoDigitYear; } else { month = parseInt(match[1]); day = parseInt(match[2]); year = parseInt(match[3]); } } else { month = parseInt(match[1]); day = parseInt(match[2]); const dateThisYear = new Date(Date.UTC(currentYear, month - 1, day)); if (dateThisYear >= today) { year = currentYear; } else { year = currentYear + 1; } } if (month < 1 || month > 12 || day < 1 || day > 31) { throw new ErrorClass( rawInput, dateString, "Unable to parse date. Supported formats: YYYY-MM-DD, MM/DD/YYYY, YYYY/MM/DD, MM-DD-YYYY, MM/DD, MM-DD" ); } const testDate = new Date(Date.UTC(year, month - 1, day)); if (testDate.getUTCFullYear() === year && testDate.getUTCMonth() === month - 1 && testDate.getUTCDate() === day) { parsedDate = testDate; break; } else { throw new ErrorClass( rawInput, dateString, `Invalid calendar date (e.g., February 30th doesn't exist)` ); } } } if (!parsedDate) { throw new ErrorClass( rawInput, dateString, "Unable to parse date. Supported formats: YYYY-MM-DD, MM/DD/YYYY, YYYY/MM/DD, MM-DD-YYYY, MM/DD, MM-DD" ); } return parsedDate; } function validateWriteinDescription(description, rawInput) { const trimmed = description.trim(); if (!trimmed) { throw new InvalidWriteinDescriptionError(rawInput, description, "Description cannot be empty"); } if (trimmed.length < 10) { throw new InvalidWriteinDescriptionError( rawInput, description, `Description must be at least 10 characters long (currently ${trimmed.length})` ); } if (trimmed.length > 255) { throw new InvalidWriteinDescriptionError( rawInput, description, `Description cannot exceed 255 characters (currently ${trimmed.length})` ); } if (trimmed.includes("\n") || trimmed.includes("\r")) { throw new InvalidWriteinDescriptionError( rawInput, description, "Description cannot contain newlines" ); } return trimmed; } function extractAndValidateKeywords(text, rawInput, allowedKeys, parser) { const parts = text.trim().split(/\s+/); const keywords = {}; const remainingParts = []; for (const part of parts) { if (part.includes(":")) { const [key, ...valueParts] = part.split(":"); if (valueParts.length === 0 || key === "" || valueParts.join(":") === "") { throw new InvalidKeywordSyntaxError( rawInput, part, "Invalid keyword syntax: no spaces allowed around colon" ); } const value = valueParts.join(":"); if (!allowedKeys.includes(key)) { throw new UnknownKeywordError(rawInput, key); } parser(key, value, keywords); } else { remainingParts.push(part); } } return { keywords, remainingParts }; } function parseKeywords(text, rawInput, allowedKeys) { const { keywords, remainingParts } = extractAndValidateKeywords( text, rawInput, allowedKeys, (key, value, keywords2) => { switch (key) { case "date": keywords2.date = value; break; case "league": keywords2.league = value; break; case "freebet": if (value !== "true") { throw new InvalidKeywordValueError( rawInput, key, value, 'Invalid freebet value: must be "true"' ); } keywords2.freebet = true; break; } } ); return { ...keywords, cleanedText: remainingParts.join(" ") }; } function parseParlayKeywords(text, rawInput, allowedKeys) { const lines = text.split("\n"); const firstLine = lines[0]; const remainingLines = lines.slice(1); const { keywords, remainingParts } = extractAndValidateKeywords( firstLine, rawInput, allowedKeys, (key, value, keywords2) => { switch (key) { case "pusheslose": case "tieslose": case "freebet": if (value !== "true") { throw new InvalidKeywordValueError( rawInput, key, value, `Invalid ${key} value: must be "true"` ); } keywords2[key] = true; break; } } ); const cleanedFirstLine = remainingParts.join(" "); const allLines = cleanedFirstLine ? [cleanedFirstLine, ...remainingLines] : remainingLines; return { ...keywords, cleanedText: allLines.join("\n") }; } function parseParlaySize(sizeText, rawInput) { if (!sizeText.startsWith("=")) { throw new InvalidSizeFormatError(rawInput, sizeText, "Size must start with ="); } const text = sizeText.slice(1).trim(); if (text.match(/towin:/i)) { throw new InvalidParlayToWinError( rawInput, 'Invalid to-win format: use "tw $500" not "towin:500"' ); } const twMatch = text.match(/^([$\d.,]+k?)\s+tw\s+([$\d.,]+k?)$/i); if (twMatch) { const riskStr = twMatch[1]; const toWinStr = twMatch[2]; const riskParsed2 = parseFillSize(riskStr, rawInput); const toWinParsed = parseFillSize(toWinStr, rawInput); return { risk: riskParsed2.value, toWin: toWinParsed.value, useFair: false }; } if (text.toLowerCase().match(/\btw\b.*\btw\b/)) { throw new InvalidParlayToWinError(rawInput, "To-win amount specified multiple times"); } if (text.match(/^[$\d.,]+k?\s+[$\d.,]+k?$/i)) { throw new InvalidSizeFormatError( rawInput, sizeText, 'Invalid to-win syntax: must use "tw" keyword' ); } const riskMatch = text.match(/^([$\d.,]+k?)$/i); if (!riskMatch) { throw new InvalidSizeFormatError( rawInput, sizeText, 'Format: "= $100", "= 2.5", "= 3k" or "= $100 tw $500"' ); } const riskParsed = parseFillSize(riskMatch[1], rawInput); return { risk: riskParsed.value, toWin: void 0, useFair: true }; } function parseRoundRobinSize(sizeText, rawInput) { if (!sizeText.startsWith("=")) { throw new InvalidSizeFormatError(rawInput, sizeText, "Size must start with ="); } const text = sizeText.slice(1).trim(); if (text.match(/towin:/i)) { throw new InvalidRoundRobinToWinError( rawInput, 'Invalid to-win format: use "tw $500" not "towin:500"' ); } if (text.match(/^(per|total)\s+\$?[\d.]+/i)) { throw new InvalidSizeFormatError(rawInput, sizeText, "Risk type must come after size amount"); } const twMatch = text.match(/^([$\d.]+k?)\s+(per|total)\s+tw\s+([$\d.]+k?)$/i); if (twMatch) { const riskStr = twMatch[1]; const riskTypeRaw2 = twMatch[2].toLowerCase(); const toWinStr = twMatch[3]; const riskParsed2 = parseFillSize(riskStr, rawInput); const toWinParsed = parseFillSize(toWinStr, rawInput); if (riskTypeRaw2 !== "per" && riskTypeRaw2 !== "total") { throw new InvalidRiskTypeError(rawInput, riskTypeRaw2); } const riskType2 = riskTypeRaw2 === "per" ? "perSelection" : "total"; return { risk: riskParsed2.value, toWin: toWinParsed.value, useFair: false, riskType: riskType2 }; } if (text.match(/^[$\d.]+k?\s+(per|total)\s+[$\d.]+k?$/i)) { throw new InvalidSizeFormatError( rawInput, sizeText, 'Invalid to-win syntax: must use "tw" keyword' ); } const sizeMatch = text.match(/^([$\d.]+k?)\s+(per|total)$/i); if (!sizeMatch) { const hasRiskOnly = text.match(/^[$\d.]+k?$/i); if (hasRiskOnly) { throw new MissingRiskTypeError(rawInput); } const hasInvalidType = text.match(/^([$\d.]+k?)\s+(\w+)$/i); if (hasInvalidType) { throw new InvalidRiskTypeError(rawInput, hasInvalidType[2]); } throw new InvalidSizeFormatError( rawInput, sizeText, 'Format: "= $100 per", "= 2.5 total", "= 3k per"' ); } const riskParsed = parseFillSize(sizeMatch[1], rawInput); const riskTypeRaw = sizeMatch[2].toLowerCase(); if (riskTypeRaw !== "per" && riskTypeRaw !== "total") { throw new InvalidRiskTypeError(rawInput, riskTypeRaw); } const riskType = riskTypeRaw === "per" ? "perSelection" : "total"; return { risk: riskParsed.value, toWin: void 0, useFair: true, riskType }; } function calculateCombination(n, r) { if (r > n) return 0; if (r === 0 || r === n) return 1; if (r > n - r) { r = n - r; } let result = 1; for (let i = 0; i < r; i++) { result *= n - i; result /= i + 1; } return Math.round(result); } function calculateTotalParlays(totalLegs, parlaySize, isAtMost) { if (isAtMost) { let total = 0; for (let r = 2; r <= parlaySize; r++) { total += calculateCombination(totalLegs, r); } return total; } else { return calculateCombination(totalLegs, parlaySize); } } function americanToDecimalOdds(americanOdds) { if (americanOdds > 0) { return 1 + americanOdds / 100; } else { return 1 + 100 / Math.abs(americanOdds); } } function calculateParlayFairToWin(legPrices, risk) { let parlayMultiplier = 1; for (const price of legPrices) { const decimalOdds = americanToDecimalOdds(price); parlayMultiplier *= decimalOdds; } const toWin = risk * (parlayMultiplier - 1); return Math.round(toWin * 100) / 100; } function* generateCombinations(arr, r) { if (r === 0) { yield []; return; } if (arr.length === 0) { return; } const [first, ...rest] = arr; for (const combo of generateCombinations(rest, r - 1)) { yield [first, ...combo]; } yield* generateCombinations(rest, r); } function calculateRoundRobinFai