pcc-tournament-pairs
Version:
Tiny swiss pairing library with basic deterministic functionality
337 lines (316 loc) • 9.91 kB
JavaScript
var blossom = require('edmonds-blossom');
// Doesn't account for forfeits yet, still debating on that implementation
// for multi-point scenarios
function getModifiedMedianScores(options, round, participants, matches) {
matches = matches.filter((match) => match.round < round);
var mappings = getMappings(participants, matches);
var points = mappings.reduce((acc, val) => {
acc[val.id] = val.points;
return acc;
}, {});
var scores = mappings.reduce(
(acc, history) => {
history.opponents.forEach((opponent) => {
// Don't calculate points for null (BYE) opponents
if (opponent) {
acc[opponent].scores.push(history.points);
acc[opponent].points += history.points;
}
});
return acc;
},
participants.reduce((acc, participant) => {
acc[participant.id] = {
scores: [],
points: 0,
};
return acc;
}, {})
);
var fifty = ((round - 1) * options.maxPerRound) / 2;
return Object.entries(scores).reduce((acc, [key, value]) => {
value.scores.sort();
if (points[key] > fifty) {
value.scores.shift();
} else if (points[key] < fifty) {
value.scores.pop();
}
acc[key] = value.scores.reduce((acc, val) => acc + val, 0);
return acc;
}, {});
}
function getStandings(options, round, participants, matches) {
matches = matches.filter((match) => match.round < round);
// var scores = getModifiedMedianScores(options, round, participants, matches);
var standings = participants.reduce((standings, participant) => {
standings[participant.id] = {
seed: participant.seed,
wins: 0,
losses: 0,
// tiebreaker: scores[participant.id],
solkov: 0,
sohne: 0,
};
return standings;
}, {});
matches.forEach((match) => {
standings[match.home.id].wins += match.home.points;
standings[match.home.id].losses += match.away.points;
// Ignore null opponents/BYEs
if (match.away.id) {
standings[match.away.id].wins += match.away.points;
standings[match.away.id].losses += match.home.points;
}
});
standings = calculateSohneSolkov(standings, matches);
return Object.entries(standings)
.reduce((standings, [key, value]) => {
standings.push({
id: key,
seed: value.seed,
score: value.wins,
wins: value.wins,
losses: value.losses,
// tiebreaker: value.tiebreaker,
solkov: value.solkov,
sohne: value.sohne,
});
return standings;
}, [])
.sort((a, b) => {
if (a.wins === b.wins) {
if (a.sohne === b.sohne) {
if (a.solkov === b.solkov) return b.seed - a.seed;
else return b.solkov - a.solkov;
} else return b.sohne - a.sohne;
} else {
return b.wins - a.wins;
}
});
}
function calculateSohneSolkov(standings, matches) {
matches.forEach((match) => {
if (match.away.id) {
standings[match.home.id].solkov += standings[match.away.id].wins;
standings[match.away.id].solkov += standings[match.home.id].wins;
standings[match.home.id].sohne +=
standings[match.away.id].wins * match.home.points;
standings[match.away.id].sohne +=
standings[match.home.id].wins * match.away.points;
}
});
return standings;
}
function getInitialMatch(participants) {
let matches = [];
let firstGroups = [];
let secondGroups = [];
let i = 0;
let j = Math.ceil(participants.length / 2);
while (i < Math.ceil(participants.length / 2)) {
firstGroups.push(participants[i].id);
secondGroups.push(participants[j] ? participants[j].id : null);
i++;
j++;
}
for (let i = 0; i < firstGroups.length; i++) {
matches.push({ home: firstGroups[i], away: secondGroups[i] });
}
return matches;
}
function getMatchups(options, round, participants, matches) {
if (matches.length === 0) {
return getInitialMatch(participants);
}
matches = matches.filter((match) => match.round < round);
var mappings = getMappings(participants, matches);
// because ids are strings but the blossom algorithm needs integers
// we create maps from int-to-id then set the ids to integers
var mapIds = new Map();
var index = 0;
var homeAwayMap = new Map();
for (var m of mappings) {
mapIds.set(index, m.id);
m.id = index++;
}
mappings = mappings.filter((m) => !m.droppedOut);
if (mappings.length % 2 === 1) {
// we simulate the bye having played against every team with a bye
// that way those teams will not get a bye again unless the matches are
// ridiculously better if they have another
// we also want it to bias toward giving byes to teams at the bottom
// of the standings
mappings.push({
id: index,
points: 0,
seed: 0,
tiebreaker: 0,
opponents: mappings
.filter((m) => {
return m.opponents.filter((o) => o === null).length > 0;
})
.map((m) => mapIds.get(m.id)),
});
mapIds.set(index, null);
}
homeAwayMap = getHomeAwayMap(mappings);
// to avoid repeatedly matching the same team up or down repeatedly
// we shuffle the inputs to the blossom algorithm to counteract
// any ordering biases it may have
mappings = shuffle(mappings, round, options.seedMultiplier);
var arr = mappings.reduce((arr, team, i, orig) => {
var opps = orig.slice(0, i).concat(orig.slice(i + 1));
for (var opp of opps) {
arr.push([
team.id,
opp.id,
-1 *
(Math.pow(team.points - opp.points, options.standingPower) +
options.rematchWeight *
team.opponents.reduce((n, o) => {
return n + (o === mapIds.get(opp.id));
}, 0)),
]);
}
return arr;
}, []);
var results = blossom(arr, true);
var matchups = [];
// Here we sort matchups by standings so that matchups and standings follow
// roughly the same order - this doesn't impact funcitonality at all
// Ordering this in the view layer should be possible, so let's move it there
// pending review
var standings = getStandings(options, round, participants, matches);
var sortedKeys = [...mapIds.keys()].sort((a, b) => {
// Float BYEs to the end
if (mapIds.get(a) === null) {
return 1;
} else if (mapIds.get(b) === null) {
return -1;
}
return (
standings.findIndex((s) => s.id === mapIds.get(a)) -
standings.findIndex((s) => s.id === mapIds.get(b))
);
});
for (var i of sortedKeys) {
if (
results[i] !== -1 &&
!matchups.reduce(
(n, r) =>
n ||
r.home === mapIds.get(results[i]) ||
r.away === mapIds.get(results[i]),
false
)
) {
var hwResult = sortHomeAway([
homeAwayMap.get(i),
homeAwayMap.get(results[i]),
]);
matchups.push({
home: mapIds.get(hwResult.home),
away: mapIds.get(hwResult.away),
});
}
}
return matchups;
}
function getMappings(participants, matches) {
return participants.reduce((acc, participant) => {
acc.push(
matches
.filter((match) => {
return (
match.home.id === participant.id || match.away.id === participant.id
);
})
.reduce(
(acc, match) => {
if (match.home.id === participant.id) {
acc.points += match.home.points;
acc.home = acc.home ? acc.home + 1 : 1;
acc.last = 'home';
acc.opponents.push(match.away.id);
} else if (match.away.id === participant.id) {
acc.points += match.away.points;
acc.away = acc.away ? acc.away + 1 : 1;
acc.last = 'away';
acc.opponents.push(match.home.id);
}
return acc;
},
{
id: participant.id,
seed: participant.seed,
droppedOut: participant.droppedOut,
points: 0,
opponents: [],
}
)
);
return acc;
}, []);
}
//get mapped home away
function getHomeAwayMap(mappings) {
var keys = new Map();
for (var i = 0; i < mappings.length; i++) {
var obj = mappings[i];
keys.set(obj.id, {
id: obj.id,
last: obj.last,
home: obj.home,
away: obj.home,
});
}
return keys;
}
//considering home away history
function sortHomeAway(homeAwayMap) {
var sortedKeys = homeAwayMap.sort((a, b) => {
if (a.last === 'home' && b.last === 'away') {
return 1;
} else if (a.last === 'away' && b.last === 'home') {
return -1;
} else if (a.home > b.home) {
return 1;
} else if (b.home > a.home) {
return -1;
}
});
return {
home: sortedKeys[0].id,
away: sortedKeys[1].id,
};
}
// Knuth shuffle from stack overflow
function shuffle(array, seed, multiplier) {
var currentIndex = array.length;
// fast, seeded PRNG from stackoverflow
var s = seed;
const random = () => {
var x = (Math.abs((((s++ * multiplier) / Math.PI) % 4) - 2) - 1) * 10000;
return x - Math.floor(x);
};
while (0 !== currentIndex) {
var randomIndex = Math.floor(random() * currentIndex--);
var temporaryValue = array[currentIndex];
array[currentIndex] = array[randomIndex];
array[randomIndex] = temporaryValue;
}
return array;
}
module.exports = (options) => {
options = options || {};
options.maxPerRound = options.maxPerRound || 1;
options.rematchWeight = options.rematchWeight || 100;
options.standingPower = options.standingPower || 2;
options.seedMultiplier = options.seedMultiplier || 6781;
return {
getModifiedMedianScores: getModifiedMedianScores.bind(null, options),
getStandings: getStandings.bind(null, options),
getMatchups: getMatchups.bind(null, options),
getMappings: getMappings,
};
};