sbc-rating-calculator
Version:
A JavaScript library for calculating Squad Building Challenge (SBC) team ratings and finding optimal squad configurations for UT
810 lines (712 loc) • 29.3 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
/**
* Helper class for SBC calculations and combinatorial operations
*/
var SolverHelper_1;
var hasRequiredSolverHelper;
function requireSolverHelper () {
if (hasRequiredSolverHelper) return SolverHelper_1;
hasRequiredSolverHelper = 1;
class SolverHelper {
/**
* Calculate the number of multisubsets of size n from a set of given length
* @param {number} setLength - Length of the original set
* @param {number} n - Size of multisubsets to generate
* @returns {number} - Number of possible multisubsets
*/
static getMultisubsetsCount(setLength, n) {
if (n === 0 || setLength === 0) return n === 0 ? 1 : 0;
return (
this.factorial(setLength + n - 1) /
(this.factorial(setLength - 1) * this.factorial(n))
);
}
/**
* Calculate factorial of a number
* @param {number} num - Number to calculate factorial for
* @returns {number} - Factorial result
*/
static factorial(num) {
if (num < 0) return 0;
if (num === 0 || num === 1) return 1;
let result = 1;
for (let i = 2; i <= num; i++) {
result *= i;
}
return result;
}
/**
* Calculate team rating
* IMPORTANT: Always calculates squad rating as if there are 11 players total,
* padding missing slots with 0 rating.
* @param {number[]} ratings - Array of individual player ratings
* @returns {number} - Calculated team rating (floored)
*/
static getRating(ratings) {
if (!ratings || ratings.length === 0) return 0;
const paddedRatings = [...ratings];
while (paddedRatings.length < 11) {
paddedRatings.push(0);
}
const sum = paddedRatings.reduce((acc, curr) => acc + curr, 0);
const avg = sum / 11; // Always divide by 11
// Calculate correction factor for players above average
const correctionFactor = paddedRatings.reduce((acc, curr) => {
return curr > avg ? acc + (curr - avg) : acc;
}, 0);
// EA's formula: Round(sum + correction) / 11, then floor
const sumWithCorrection = sum + correctionFactor;
const rounded = Math.round(sumWithCorrection);
return Math.floor(rounded / 11);
}
/**
* Calculate total price for a set of ratings
* @param {number[]} ratings - Array of player ratings
* @param {Object.<number, number>} priceByRating - Mapping of ratings to prices
* @returns {number} - Total price
*/
static getPrice(ratings, priceByRating) {
return ratings.reduce(
(acc, curr) => acc + (priceByRating[curr] || 0),
0
);
}
/**
* Generate all multisubsets of size n from the given set
* @param {Array} set - The original set to generate subsets from
* @param {number} n - Size of each multisubset
* @returns {Generator} - Generator yielding multisubsets
*/
static getMultisubsets(set, n) {
return this.multisubsetsImpl(set, n);
}
/**
* Internal implementation for generating multisubsets
* @param {Array} set - The set to generate from
* @param {number} n - Size of subsets
* @returns {Generator} - Generator yielding multisubsets
*/
static* multisubsetsImpl(set, n) {
if (n === 0) {
yield [];
} else if (set.length > 0) {
const [x, ...rest] = set;
// Include x 0 to n times, then recurse on the rest
for (let i = 0; i <= n; i++) {
yield* this.prependNTimes(x, this.multisubsetsImpl(rest, n - i), i);
}
}
}
/**
* Prepend element 'a' n times to each array in the generator
* @param {*} a - Element to prepend
* @param {Generator} xss - Generator of arrays
* @param {number} n - Number of times to prepend
* @returns {Generator} - Generator yielding arrays with prepended elements
*/
static* prependNTimes(a, xss, n) {
const prefix = Array(n).fill(a);
for (let xs of xss) {
yield [...prefix, ...xs];
}
}
/**
* Validate input parameters for SBC calculations
* @param {Object} params - Parameters to validate
* @returns {Object} - Validation result
*/
static validateInputs(params) {
const { targetRating, existingRatings, availableRatings, squadSize } = params;
const errors = [];
if (typeof targetRating !== 'number' || targetRating < 45 || targetRating > 99) {
errors.push('Target rating must be a number between 45 and 99');
}
if (!Array.isArray(existingRatings)) {
errors.push('Existing ratings must be an array');
} else {
existingRatings.forEach((rating, index) => {
if (typeof rating !== 'number' || rating < 45 || rating > 99) {
errors.push(`Invalid rating at index ${index}: ${rating}`);
}
});
}
if (!Array.isArray(availableRatings)) {
errors.push('Available ratings must be an array');
} else {
availableRatings.forEach((rating, index) => {
if (typeof rating !== 'number' || rating < 45 || rating > 99) {
errors.push(`Invalid available rating at index ${index}: ${rating}`);
}
});
}
if (typeof squadSize !== 'number' || squadSize < 1 || squadSize > 23) {
errors.push('Squad size must be a number between 1 and 23');
}
if (existingRatings && squadSize && existingRatings.length > squadSize) {
errors.push('Existing ratings exceed squad size');
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Get unique ratings from an array
* @param {number[]} ratings - Array of ratings
* @returns {number[]} - Array of unique ratings sorted ascending
*/
static getUniqueRatings(ratings) {
return [...new Set(ratings)].sort((a, b) => a - b);
}
/**
* Count occurrences of each rating
* @param {number[]} ratings - Array of ratings
* @returns {Object.<number, number>} - Object mapping ratings to counts
*/
static countRatings(ratings) {
return ratings.reduce((acc, rating) => {
acc[rating] = (acc[rating] || 0) + 1;
return acc;
}, {});
}
}
SolverHelper_1 = { SolverHelper };
return SolverHelper_1;
}
/**
* Pre-calculated optimal SBC combinations for ratings 80-92
* Each combination is sorted by rating efficiency (lowest total rating points)
* Format: { rating: count } where rating is player rating and count is how many needed
*/
var OptimalCombinations;
var hasRequiredOptimalCombinations;
function requireOptimalCombinations () {
if (hasRequiredOptimalCombinations) return OptimalCombinations;
hasRequiredOptimalCombinations = 1;
const OPTIMAL_SBC_COMBINATIONS = {
80: [
{ 82: 1, 81: 2, 80: 8 }, // 901 total points
{ 83: 1, 80: 10 }, // 913 total points
{ 82: 2, 80: 9 }, // 904 total points
{ 81: 4, 80: 7 }, // 884 total points
{ 84: 1, 79: 10 }, // 874 total points
{ 82: 3, 79: 8 }, // 878 total points
],
81: [
{ 82: 3, 81: 8 }, // 894 total points
{ 83: 1, 81: 10 }, // 893 total points
{ 84: 1, 80: 10 }, // 884 total points
{ 82: 5, 80: 6 }, // 890 total points
{ 83: 2, 80: 9 }, // 886 total points
{ 85: 1, 79: 10 }, // 875 total points
],
82: [
{ 83: 2, 82: 9 }, // 904 total points
{ 84: 1, 82: 10 }, // 904 total points
{ 83: 4, 81: 7 }, // 899 total points
{ 85: 1, 81: 10 }, // 895 total points
{ 84: 2, 81: 9 }, // 897 total points
{ 86: 1, 80: 10 }, // 886 total points
],
83: [
{ 83: 9, 82: 2 }, // 911 total points (OPTIMAL)
{ 84: 1, 83: 6, 82: 4 }, // 910 total points
{ 85: 1, 82: 10 }, // 905 total points
{ 84: 2, 83: 7, 82: 2 }, // 913 total points
{ 86: 1, 82: 10 }, // 902 total points
{ 84: 3, 82: 8 }, // 908 total points
],
84: [
{ 84: 11 }, // 924 total points
{ 85: 1, 84: 8, 83: 2 }, // 924 total points
{ 86: 1, 83: 10 }, // 916 total points
{ 85: 2, 84: 7, 83: 2 }, // 924 total points
{ 87: 1, 83: 10 }, // 917 total points
{ 85: 3, 83: 8 }, // 919 total points
],
85: [
{ 85: 11 }, // 935 total points
{ 86: 1, 85: 8, 84: 2 }, // 935 total points
{ 87: 1, 84: 10 }, // 927 total points
{ 86: 2, 85: 7, 84: 2 }, // 935 total points
{ 88: 1, 84: 10 }, // 928 total points
{ 86: 3, 84: 8 }, // 930 total points
],
86: [
{ 86: 11 }, // 946 total points
{ 87: 1, 86: 8, 85: 2 }, // 946 total points
{ 88: 1, 85: 10 }, // 938 total points
{ 87: 2, 86: 7, 85: 2 }, // 946 total points
{ 89: 1, 85: 10 }, // 939 total points
{ 87: 3, 85: 8 }, // 941 total points
],
87: [
{ 87: 11 }, // 957 total points
{ 88: 1, 87: 8, 86: 2 }, // 957 total points
{ 89: 1, 86: 10 }, // 949 total points
{ 88: 2, 87: 7, 86: 2 }, // 957 total points
{ 90: 1, 86: 10 }, // 950 total points
{ 88: 3, 86: 8 }, // 952 total points
],
88: [
{ 88: 11 }, // 968 total points
{ 89: 1, 88: 8, 87: 2 }, // 968 total points
{ 90: 1, 87: 10 }, // 960 total points
{ 89: 2, 88: 7, 87: 2 }, // 968 total points
{ 91: 1, 87: 10 }, // 961 total points
{ 89: 3, 87: 8 }, // 963 total points
],
89: [
{ 89: 11 }, // 979 total points
{ 90: 1, 89: 8, 88: 2 }, // 979 total points
{ 91: 1, 88: 10 }, // 971 total points
{ 90: 2, 89: 7, 88: 2 }, // 979 total points
{ 92: 1, 88: 10 }, // 972 total points
{ 90: 3, 88: 8 }, // 974 total points
],
90: [
{ 90: 11 }, // 990 total points
{ 91: 1, 90: 8, 89: 2 }, // 990 total points
{ 92: 1, 89: 10 }, // 982 total points
{ 91: 2, 90: 7, 89: 2 }, // 990 total points
{ 93: 1, 89: 10 }, // 983 total points
{ 91: 3, 89: 8 }, // 985 total points
],
91: [
{ 91: 11 }, // 1001 total points
{ 92: 1, 91: 8, 90: 2 }, // 1001 total points
{ 93: 1, 90: 10 }, // 993 total points
{ 92: 2, 91: 7, 90: 2 }, // 1001 total points
{ 94: 1, 90: 10 }, // 994 total points
{ 92: 3, 90: 8 }, // 996 total points
],
92: [
{ 92: 11 }, // 1012 total points
{ 93: 1, 92: 8, 91: 2 }, // 1012 total points
{ 94: 1, 91: 10 }, // 1004 total points
{ 93: 2, 92: 7, 91: 2 }, // 1012 total points
{ 95: 1, 91: 10 }, // 1005 total points
{ 93: 3, 91: 8 }, // 1007 total points
]
};
/**
* Get optimal combinations for a target rating
* @param {number} targetRating - Target squad rating (80-92)
* @returns {Array} - Array of optimal combinations sorted by efficiency
*/
function getOptimalCombinations(targetRating) {
if (targetRating < 80 || targetRating > 92) {
return [];
}
return OPTIMAL_SBC_COMBINATIONS[targetRating] || [];
}
/**
* Check if a combination is possible with available cards
* @param {Object} combination - Rating combination {rating: count}
* @param {Object} availableCounts - Available card counts {rating: count}
* @returns {boolean} - True if combination is possible
*/
function isCombinationPossible(combination, availableCounts) {
for (const [rating, needed] of Object.entries(combination)) {
const available = availableCounts[parseInt(rating)] || 0;
if (available < needed) {
return false;
}
}
return true;
}
/**
* Convert combination object to array format for SBC solver
* @param {Object} combination - Rating combination {rating: count}
* @returns {Array} - Array of individual ratings
*/
function combinationToArray(combination) {
const result = [];
for (const [rating, count] of Object.entries(combination)) {
for (let i = 0; i < count; i++) {
result.push(parseInt(rating));
}
}
return result;
}
/**
* Calculate total rating points for a combination
* @param {Object} combination - Rating combination {rating: count}
* @returns {number} - Total rating points
*/
function calculateCombinationPoints(combination) {
let total = 0;
for (const [rating, count] of Object.entries(combination)) {
total += parseInt(rating) * count;
}
return total;
}
OptimalCombinations = {
OPTIMAL_SBC_COMBINATIONS,
getOptimalCombinations,
isCombinationPossible,
combinationToArray,
calculateCombinationPoints
};
return OptimalCombinations;
}
var src;
var hasRequiredSrc;
function requireSrc () {
if (hasRequiredSrc) return src;
hasRequiredSrc = 1;
const { SolverHelper } = requireSolverHelper();
const { getOptimalCombinations, isCombinationPossible, combinationToArray, calculateCombinationPoints } = requireOptimalCombinations();
/**
* SBC Rating Calculator - A comprehensive library for Squad Building Challenges
*/
class SBCRatingCalculator {
constructor(options = {}) {
this.defaultSquadSize = options.defaultSquadSize || 11;
this.defaultMaxSolutions = options.defaultMaxSolutions || 50;
}
/**
* Calculate the team rating based on individual player ratings
* Always calculates as if there are 11 players, padding missing slots with 0
* @param {number[]} ratings - Array of player ratings
* @returns {number} - Calculated team rating
*/
calculateTeamRating(ratings) {
return SolverHelper.getRating(ratings);
}
/**
* Find optimal SBC solutions using pre-calculated combinations (fast method for large inventories)
* @param {Object} options - Configuration options
* @param {number} options.targetRating - The desired overall squad rating (80-92)
* @param {number[]} options.availableRatings - Large array of available player ratings
* @param {Object.<number, number>} options.priceByRating - Mapping of player ratings to their prices
* @param {number} options.maxSolutions - Maximum number of solutions to return
* @returns {Object} - Result object with optimal solutions
*/
findOptimalSolutions(options) {
const {
targetRating,
availableRatings = [],
priceByRating = {},
maxSolutions = 10
} = options;
// Get pre-calculated optimal combinations
const optimalCombinations = getOptimalCombinations(targetRating);
if (optimalCombinations.length === 0) {
return {
solutionsFound: 0,
solutions: [],
message: `No optimal combinations available for rating ${targetRating}. Use findSquadSolutions for custom search.`
};
}
// Count available cards
const availableCounts = SolverHelper.countRatings(availableRatings);
const solutions = [];
for (const combination of optimalCombinations.slice(0, maxSolutions)) {
// Check if this combination is possible with available cards
if (isCombinationPossible(combination, availableCounts)) {
// Calculate price and create solution
const ratingArray = combinationToArray(combination);
const price = SolverHelper.getPrice(ratingArray, priceByRating);
const totalRatingPoints = calculateCombinationPoints(combination);
// Convert to squad format
const squad = Object.entries(combination).map(([rating, count]) => ({
rating: parseInt(rating),
count: count
}));
solutions.push({
price: price,
squad: squad,
actualRating: targetRating,
totalRatingPoints: totalRatingPoints,
efficiency: totalRatingPoints / targetRating,
isOptimal: true
});
}
}
return {
solutionsFound: solutions.length,
solutions: solutions.sort((a, b) => a.totalRatingPoints - b.totalRatingPoints)
};
}
/**
* Calculate possible squad configurations to meet a target rating for an SBC
* Prioritizes solutions by rating efficiency (lowest total rating points used)
* @param {Object} options - Configuration options
* @param {number} options.targetRating - The desired overall squad rating
* @param {number[]} options.existingRatings - Ratings of players already in the squad
* @param {number[]} options.availableRatings - Ratings of players available to fill the squad
* @param {Object.<number, number>} options.priceByRating - Mapping of player ratings to their prices
* @param {number} options.squadSize - The total number of players in the squad
* @param {number} options.maxSolutions - The maximum number of solutions to return
* @param {boolean} options.sortByPrice - If true, sort by price; if false, sort by rating efficiency
* @param {boolean} options.useOptimalCombinations - If true, use pre-calculated optimal combinations for speed
* @returns {Object} - Result object with solutions found and solution details
*/
findSquadSolutions(options) {
const {
targetRating,
existingRatings = [],
availableRatings = [],
priceByRating = {},
squadSize = this.defaultSquadSize,
maxSolutions = this.defaultMaxSolutions,
sortByPrice = true,
useOptimalCombinations = true
} = options;
// If no existing ratings and target is in optimal range, use fast method
if (existingRatings.length === 0 &&
useOptimalCombinations &&
targetRating >= 80 &&
targetRating <= 92 &&
availableRatings.length > 50) {
const optimalResult = this.findOptimalSolutions({
targetRating,
availableRatings,
priceByRating,
maxSolutions
});
if (optimalResult.solutionsFound > 0) {
return optimalResult;
}
}
// Validate inputs
const validation = SolverHelper.validateInputs({
targetRating,
existingRatings,
availableRatings: availableRatings,
squadSize
});
if (!validation.valid) {
return {
error: validation.errors.join(', '),
solutionsFound: 0,
solutions: []
};
}
return calculateSquadSolutions(
{
targetRating,
existingRatings,
ratingsToTry: availableRatings,
priceByRating
},
squadSize,
maxSolutions,
sortByPrice
);
}
/**
* Find the most efficient squad solutions (lowest rating cost)
* @param {Object} options - Same as findSquadSolutions but sorted by efficiency
* @returns {Object} - Solutions sorted by rating efficiency
*/
findMostEfficientSolutions(options) {
return this.findSquadSolutions({
...options,
sortByPrice: false
});
}
/**
* Calculate the minimum rating needed for remaining players to reach target
* @param {number} targetRating - Desired team rating
* @param {number[]} existingRatings - Current player ratings
* @param {number} remainingSlots - Number of players still to add
* @param {number} squadSize - Total squad size (default: 11)
* @returns {number} - Minimum rating needed for each remaining player
*/
calculateMinimumRatingNeeded(targetRating, existingRatings, remainingSlots, squadSize = 11) {
const currentSum = existingRatings.reduce((sum, rating) => sum + rating, 0);
const targetSum = targetRating * 11; // Always 11 for EA
const remainingSum = targetSum - currentSum;
return Math.ceil(remainingSum / remainingSlots);
}
/**
* Validate if a squad configuration can achieve the target rating
* @param {number[]} ratings - All player ratings in the squad
* @param {number} targetRating - Desired team rating
* @param {number} squadSize - Total squad size (default: 11)
* @returns {Object} - Validation result with success status and actual rating
*/
validateSquad(ratings, targetRating, squadSize = 11) {
// For validation, we check if it's a full 11-player squad
if (ratings.length !== squadSize) {
return {
valid: false,
message: `Squad must have exactly ${squadSize} players`,
actualRating: null
};
}
const actualRating = this.calculateTeamRating(ratings);
const valid = actualRating >= targetRating;
return {
valid,
actualRating,
targetRating,
message: valid
? `Squad achieves target rating (${actualRating} >= ${targetRating})`
: `Squad rating too low (${actualRating} < ${targetRating})`
};
}
/**
* Get statistics about available player ratings
* @param {number[]} ratings - Array of available player ratings
* @returns {Object} - Statistical information about the ratings
*/
getRatingStatistics(ratings) {
if (!ratings || ratings.length === 0) {
return { min: 0, max: 0, average: 0, median: 0, count: 0 };
}
const sorted = [...ratings].sort((a, b) => a - b);
const count = ratings.length;
const sum = ratings.reduce((acc, rating) => acc + rating, 0);
const average = Math.round(sum / count * 100) / 100;
const median = count % 2 === 0
? (sorted[count / 2 - 1] + sorted[count / 2]) / 2
: sorted[Math.floor(count / 2)];
return {
min: sorted[0],
max: sorted[count - 1],
average,
median,
count
};
}
}
/**
* Calculate possible squad configurations to meet a target rating for an SBC.
* Enhanced to prioritize solutions by rating efficiency or price.
*
* @param {Object} solverOptions - Options to configure the SBC solver.
* @param {number} solverOptions.targetRating - The desired overall squad rating.
* @param {number[]} solverOptions.existingRatings - Ratings of players already in the squad.
* @param {number[]} solverOptions.ratingsToTry - Ratings of players available to fill the squad.
* @param {Object.<number, number>} solverOptions.priceByRating - Mapping of player ratings to their prices.
* @param {number} SQUAD_SIZE - The total number of players in the squad.
* @param {number} MAX_SOLUTIONS_TO_TAKE - The maximum number of solutions to return.
* @param {boolean} sortByPrice - If true, sort by price; if false, sort by rating efficiency.
*
* @returns {Object} - An object containing the number of solutions found and the solutions themselves.
* @returns {number} return.solutionsFound - The number of valid squad configurations found.
* @returns {Array<Object>} return.solutions - An array of solutions, each with a squad configuration and its cost.
*/
function calculateSquadSolutions(solverOptions, SQUAD_SIZE, MAX_SOLUTIONS_TO_TAKE, sortByPrice = true) {
try {
const { targetRating, existingRatings, ratingsToTry, priceByRating } = solverOptions;
let solutions = [];
if (targetRating > 99 || targetRating < 45) {
return {
solutionsFound: 0,
solutions: []
};
}
const remainingSlots = SQUAD_SIZE - existingRatings.length;
if (remainingSlots <= 0) {
// Check if existing squad already meets the requirement
const currentRating = SolverHelper.getRating(existingRatings);
if (currentRating >= targetRating) {
return {
solutionsFound: 1,
solutions: [{
price: 0,
squad: [],
actualRating: currentRating,
totalRatingPoints: existingRatings.reduce((a, b) => a + b, 0),
efficiency: 0
}]
};
} else {
return {
solutionsFound: 0,
solutions: []
};
}
}
// Count available ratings for better optimization
const availableCounts = SolverHelper.countRatings(ratingsToTry);
const combinations = SolverHelper.getMultisubsets(ratingsToTry, remainingSlots);
for (const combination of combinations) {
// Check if we have enough cards of each rating
const neededCounts = SolverHelper.countRatings(combination);
let hasEnoughCards = true;
for (const [rating, needed] of Object.entries(neededCounts)) {
if ((availableCounts[rating] || 0) < needed) {
hasEnoughCards = false;
break;
}
}
if (!hasEnoughCards) continue;
const fullSquad = [...existingRatings, ...combination];
const rating = SolverHelper.getRating(fullSquad);
if (rating < targetRating) {
continue;
}
const ratingCounts = combination.reduce(function(acc, curr) {
acc[curr] = (acc[curr] || 0) + 1;
return acc;
}, {});
const squad = Object.keys(ratingCounts).map(function(rating) {
return {
rating: +rating,
count: ratingCounts[+rating]
};
});
const totalRatingPoints = fullSquad.reduce((a, b) => a + b, 0);
const price = SolverHelper.getPrice(combination, priceByRating);
// Calculate efficiency (lower is better)
const efficiency = totalRatingPoints / Math.max(rating, 1);
solutions.push({
price: price,
squad: squad,
actualRating: rating,
totalRatingPoints: totalRatingPoints,
efficiency: efficiency,
combinationUsed: combination
});
}
// Sort by price or efficiency
if (sortByPrice) {
solutions.sort(function(a, b) {
// Primary: price, Secondary: efficiency (rating points)
if (a.price !== b.price) {
return a.price - b.price;
}
return a.totalRatingPoints - b.totalRatingPoints;
});
} else {
solutions.sort(function(a, b) {
// Primary: total rating points (efficiency), Secondary: price
if (a.totalRatingPoints !== b.totalRatingPoints) {
return a.totalRatingPoints - b.totalRatingPoints;
}
return a.price - b.price;
});
}
if (MAX_SOLUTIONS_TO_TAKE && MAX_SOLUTIONS_TO_TAKE > 0) {
solutions = solutions.slice(0, MAX_SOLUTIONS_TO_TAKE);
}
return {
solutionsFound: solutions.length,
solutions: solutions
};
} catch (error) {
return {
error: error.message || "An error occurred",
solutionsFound: 0,
solutions: []
};
}
}
// CommonJS exports
src = {
SBCRatingCalculator,
calculateSquadSolutions,
SolverHelper
};
return src;
}
var srcExports = requireSrc();
var index = srcExports.default;
exports.default = index;
//# sourceMappingURL=index.js.map