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
JavaScript
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