bracket-validator
Version:
Validate a tournament bracket.
392 lines (331 loc) • 14.1 kB
JavaScript
var bracketData = require('bracket-data')
var _all = require('lodash/every')
var _include = require('lodash/includes')
var _map = require('lodash/map')
var _toArray = require('lodash/toArray')
var _range = require('lodash/range')
var _keys = require('lodash/keys')
var _defaults = require('lodash/defaults')
var _omit = require('lodash/omit')
var _difference = require('lodash/difference')
var _isArray = require('lodash/isArray')
var _some = require('lodash/some')
var _every = require('lodash/every')
var _filter = require('lodash/filter')
var _find = require('lodash/find')
var _each = require('lodash/forEach')
var _extend = require('lodash/assign')
var _last = require('lodash/last')
var _without = require('lodash/without')
var _compact = require('lodash/compact')
var _uniqBy = require('lodash/uniqBy')
var _indexOf = require('lodash/indexOf')
var _reduce = require('lodash/reduce')
var _subset = function (small, big) {
if (small.length === 0) return true
return _all(small, function (n) {
return _include(big, n)
})
}
var hasError = function (result) {
return _isArray(result) ? _some(result, function (r) { return r instanceof Error }) : result instanceof Error
}
var getErrors = function (result) {
return _isArray(result) ? _filter(result, function (r) { return r instanceof Error })[0] : result
}
var findResult = function (result) {
return _isArray(result) ? _map(result, 'result') : result.result
}
var wrapError = function () {
return {
error: true,
result: new Error(_map(_toArray(arguments), function (arg) {
return (typeof arg.message === 'string') ? arg.message : arg.toString()
}).join(' '))
}
}
var wrapSuccess = function (result) {
return {
error: null,
result: result
}
}
var winningTeamFromRegion = function (bracket, regionName) {
return _last(_find(bracket, function (b) { return b.id === regionName }).rounds)[0]
}
function Validator (options) {
_defaults(options, {
testOnly: false,
allowEmpty: true,
flatBracket: ''
})
this.options = _omit(options, 'flatBracket')
this.bracketData = bracketData({
year: options.year,
sport: options.sport
})
return this.reset(options.flatBracket)
}
Validator.prototype.reset = function (flatBracket) {
if (typeof flatBracket !== 'string') flatBracket = ''
this.flatBracket = flatBracket.toUpperCase()
return this
}
Validator.prototype.validate = function (flatBracket) {
flatBracket && this.reset(flatBracket)
var result = this.flatBracket
var self = this
// Test expansion from flat to JSON
result = findResult(this.expandFlatBracket(result, this.options.allowEmpty))
if (hasError(result)) return getErrors(result)
// Test if JSON has all the keys
result = findResult(this.hasNecessaryKeys(result))
if (hasError(result)) return getErrors(result)
// Picks to arrays
result = findResult(_map(result, this.picksToArray.bind(this)))
if (hasError(result)) return getErrors(result)
// Array to nested array
result = findResult(_map(result, this.getRounds.bind(this)))
if (hasError(result)) return getErrors(result)
// All regions have valid picks
result = findResult(_map(result, this.validatePicks.bind(this)))
if (hasError(result)) return getErrors(result)
// Final region has valid picks
result = findResult(this.validateFinal(_find(result, function (item) { return item.id === self.bracketData.constants.FINAL_ID }), result))
if (hasError(result)) return getErrors(result)
// Testing only return flat bracktet
if (this.options.testOnly) return this.flatBracket
// Decorate with data
result = findResult(this.decorateValidated(result))
if (hasError(result)) return getErrors(result)
return result
}
Validator.prototype.expandFlatBracket = function (flat, allowEmpty) {
if (!allowEmpty && flat.indexOf(this.bracketData.constants.UNPICKED_MATCH) > -1) {
return wrapError('Bracket has unpicked matches')
}
var length = this.bracketData.regex.source.split('(').length
var range = _range(1, length)
var replacer = _map(range, function (i) {
var prepend = (i === 1) ? '{"$' : ''
var append = (i % 2) ? '":"$' : ((i < length - 1) ? '","$' : '"}')
return prepend + i + append
}).join('')
try {
return wrapSuccess(JSON.parse(flat.replace(this.bracketData.regex, replacer)))
} catch (e) {
return wrapError('Bracket does not look like a bracket')
}
}
Validator.prototype.hasNecessaryKeys = function (obj) {
var hasKeys = _keys(obj)
var hasAllKeys = !!(this.bracketData.constants.ALL_IDS.length === hasKeys.length && _difference(this.bracketData.constants.ALL_IDS, hasKeys).length === 0)
if (hasAllKeys) {
return wrapSuccess(obj)
}
return wrapError('Bracket does not have the corret keys. Missing:', _difference(this.bracketData.constants.ALL_IDS, hasKeys).join(','))
}
Validator.prototype.decorateValidated = function (bracket) {
var decorated = {}
var self = this
_each(bracket, function (region) {
decorated[region.id] = _extend({}, region, self.bracketData.bracket.regions[region.id] || self.bracketData.bracket[self.bracketData.constants.FINAL_ID])
decorated[region.id].rounds = _map(region.rounds, function (round, roundIndex) {
var returnRound = []
_each(round, function (seed, index) {
if (seed === self.bracketData.constants.UNPICKED_MATCH) {
returnRound[index] = null
} else if (region.id === self.bracketData.constants.FINAL_ID) {
var winningTeam = winningTeamFromRegion(bracket, seed)
if (winningTeam === self.bracketData.constants.UNPICKED_MATCH) {
returnRound[index] = null
} else {
returnRound[index] = {
fromRegion: seed,
seed: winningTeam,
winsIn: region.winsIn[roundIndex][index],
name: self.teamNameFromRegion(seed, winningTeam)
}
if (roundIndex === 0 || returnRound[index].winsIn == null) delete returnRound[index].winsIn
}
} else {
returnRound[index] = {
fromRegion: region.id,
seed: seed,
winsIn: region.winsIn[roundIndex][index],
name: self.teamNameFromRegion(region.id, seed)
}
if (roundIndex === 0 || returnRound[index].winsIn == null) delete returnRound[index].winsIn
}
})
return returnRound
})
})
return wrapSuccess(decorated)
}
Validator.prototype.teamNameFromRegion = function (regionName, seed) {
return this.bracketData.bracket.regions[regionName].teams[seed - 1]
}
// Takes an array of picks and a regionName
// Validates picks to make sure that all the individual picks are valid
// including each round having the correct number of games
// and each pick being a team that has not been eliminated yet
Validator.prototype.validatePicks = function (options) {
options = options || {}
var self = this
var rounds = options.rounds || []
var winsIn = options.winsIn || []
var regionName = options.id
var length = rounds.length
var regionPicks = {}
var errors = []
_each(rounds, function (round, i) {
var winsInThis = winsIn[i]
var requiredLength = (Math.pow(2, length - 1) / Math.pow(2, i))
var nextRound = rounds[i + 1]
var correctLength = (round.length === requiredLength)
var lastItem = (i === length - 1)
var thisRoundPickedGames = _without(round, self.bracketData.constants.UNPICKED_MATCH)
var nextRoundPickedGames = (nextRound) ? _without(nextRound, self.bracketData.constants.UNPICKED_MATCH) : []
var nextRoundIsSubset = (!lastItem && _subset(nextRoundPickedGames, thisRoundPickedGames))
var winsInCorrect = _every(winsInThis, function (w) { return w === null || self.bracketData.constants.BEST_OF_RANGE.indexOf(w) > -1 })
if (correctLength && winsInCorrect && (lastItem || nextRoundIsSubset)) {
regionPicks.id = options.id
regionPicks.rounds = rounds
regionPicks.winsIn = winsIn
} else if (!correctLength) {
errors.push('Incorrect number of pick in: ' + regionName + (i + 1))
} else if (!nextRoundIsSubset) {
errors.push('Round is not a subset of previous: ' + regionName + (i + 2))
} else if (!winsInCorrect) {
errors.push('Round has incorrect possible winsIn in: ' + regionName + (i + 1))
}
})
return (!errors.length) ? wrapSuccess(regionPicks) : wrapError(errors)
}
// Takes an array of values and removes all invalids
// return an array or arrays where each subarray is one round
Validator.prototype.getRounds = function (options) {
var self = this
options = options || {}
var rounds = options.picks || []
var winsIn = options.winsIn || []
var regionName = options.id || ''
var length = rounds.length + 1
var retRounds = [(regionName === this.bracketData.constants.FINAL_ID) ? this.bracketData.constants.REGION_IDS : this.bracketData.order]
var retWinsIn = [[]]
var verify = function (arr, keep) {
// Compacts the array and remove all duplicates that are not "X"
return _compact(_uniqBy(arr, function (n) { return (_indexOf(keep, n) > -1) ? n + Math.random() : n }))
}
var checkVal = function (val) {
var num = parseInt(val, 10)
if (num >= 1 && num <= self.bracketData.constants.TEAMS_PER_REGION) {
return num
} else if (val === self.bracketData.constants.UNPICKED_MATCH) {
return val
} else if (_include(self.bracketData.constants.REGION_IDS, val)) {
return val
} else {
return 0
}
}
var checkWinsInVal = function (val) {
if (self.bracketData.constants.BEST_OF_RANGE.indexOf(val) > -1) {
return val
} else {
return null
}
}
while (length > 1) {
length = length / 2
var roundGames = verify(_map(rounds.splice(0, Math.floor(length)), checkVal), [this.bracketData.constants.UNPICKED_MATCH])
retWinsIn.push(_map(winsIn.splice(0, Math.floor(length)), checkWinsInVal))
retRounds.push(roundGames)
}
return retRounds.length ? wrapSuccess({rounds: retRounds, winsIn: retWinsIn, id: regionName}) : wrapError('Could not get rounds from:', regionName)
}
// Takes a string of the picks for a region and validates them
// Return an array of picks if valid or false if invalid
Validator.prototype.picksToArray = function (picks, regionName) {
var self = this
var rTestRegionPicks = null
var regExpGroups = []
var bestOf = self.bracketData.constants.BEST_OF_RANGE
var captureGroupCount = bestOf ? 2 : 1
var firstRoundLength = (regionName === this.bracketData.constants.FINAL_ID) ? this.bracketData.constants.REGION_COUNT : this.bracketData.constants.TEAMS_PER_REGION
var replacement = '$' + _range(1, bestOf ? ((firstRoundLength * 2) - 1) : firstRoundLength).join(',$')
var seeds = (regionName === this.bracketData.constants.FINAL_ID) ? this.bracketData.constants.REGION_IDS : this.bracketData.order
var regExpJoiner = function (arr, reverse) {
var newArr = (reverse) ? arr.reverse() : arr
return '(' + newArr.join('|') + '|' + self.bracketData.constants.UNPICKED_MATCH + ')'
}
var backref = function (start, stop, step) {
// stop + 1 is so its inclusive
return regExpJoiner(_map(_range(start, stop + 1, step), function (n) { return '\\' + n }), true)
}
var i
if (regionName === this.bracketData.constants.FINAL_ID) {
// Allow order independent final picks, we'll validate against matchups later
for (i = 0; i < this.bracketData.constants.REGION_COUNT - 1; i++) {
regExpGroups.push(regExpJoiner(seeds.slice(0, this.bracketData.constants.REGION_COUNT)))
if (i > 0 && i === this.bracketData.constants.REGION_COUNT - 1) {
regExpGroups.push(backref(1, 2))
}
}
} else {
// Create capture groups for the first round of the region
for (i = 0; i < firstRoundLength; i += 2) {
regExpGroups.push(regExpJoiner(seeds.slice(i, i + 2)))
}
// Create capture groups using backreferences for the capture groups above
for (i = 1; i < (firstRoundLength * captureGroupCount) - 2; i += (2 * captureGroupCount)) {
regExpGroups.push(backref(i, i + captureGroupCount, captureGroupCount))
}
}
if (bestOf) {
regExpGroups = _reduce(regExpGroups, function (memo, group) {
memo.push(group)
memo.push('(' + bestOf.join('|') + ')?')
return memo
}, [])
}
rTestRegionPicks = new RegExp(regExpGroups.join(''))
if (rTestRegionPicks.test(picks)) {
var matchPicks = []
var winsInPicks = []
_each(picks.replace(rTestRegionPicks, replacement).split(','), function (pick, index) {
if (bestOf && index % 2) {
winsInPicks.push(pick ? +pick : null)
} else {
matchPicks.push(pick)
}
})
return wrapSuccess({picks: matchPicks, winsIn: winsInPicks, id: regionName})
} else {
return wrapError('Unable to parse picks in region:', regionName)
}
}
Validator.prototype.validateFinal = function (finalPicks, validatedRounds) {
var semifinal = finalPicks.rounds[1]
if (_include(semifinal, this.bracketData.constants.UNPICKED_MATCH)) {
return wrapSuccess(validatedRounds)
}
for (var i = 0, m = validatedRounds.length; i < m; i++) {
var regionId = validatedRounds[i].id
var regionWinner = _last(validatedRounds[i].rounds)[0]
if (regionId !== this.bracketData.constants.FINAL_ID && regionWinner === this.bracketData.constants.UNPICKED_MATCH && _include(semifinal, regionId)) {
return wrapError('Final teams are selected without all regions finished')
}
}
var playingItself = (semifinal[0] === semifinal[1])
var playingWrongSide = (this.bracketData.bracket.regions[semifinal[0]].sameSideAs === semifinal[1])
if (!_subset(semifinal, this.bracketData.constants.REGION_IDS)) {
return wrapError('The championship game participants are invalid.')
} else if (playingItself || playingWrongSide) {
return wrapError('The championship game participants are from the same side of the bracket.')
} else {
return wrapSuccess(validatedRounds)
}
}
module.exports = Validator