Swiss pairing in progress
This commit is contained in:
@@ -251,6 +251,12 @@
|
||||
<artifactId>jeasse-servlet3</artifactId>
|
||||
<version>1.2</version>
|
||||
</dependency>
|
||||
<!-- graph solver -->
|
||||
<dependency>
|
||||
<groupId>org.jgrapht</groupId>
|
||||
<artifactId>jgrapht-core</artifactId>
|
||||
<version>1.5.2</version>
|
||||
</dependency>
|
||||
<!-- tests -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
|
@@ -1,21 +1,53 @@
|
||||
package org.jeudego.pairgoth.api
|
||||
|
||||
import com.republicate.kson.Json
|
||||
import com.republicate.kson.toJsonArray
|
||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||
import org.jeudego.pairgoth.model.Pairing
|
||||
import org.jeudego.pairgoth.model.Tournament
|
||||
import org.jeudego.pairgoth.model.toJson
|
||||
import org.jeudego.pairgoth.store.Store
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
object PairingHandler: ApiHandler {
|
||||
|
||||
private fun getTournament(request: HttpServletRequest): Tournament {
|
||||
val tournamentId = getSelector(request)?.toIntOrNull() ?: ApiHandler.badRequest("invalid tournament id")
|
||||
return Store.getTournament(tournamentId) ?: ApiHandler.badRequest("unknown tournament id")
|
||||
val tournamentId = getSelector(request)?.toIntOrNull() ?: badRequest("invalid tournament id")
|
||||
return Store.getTournament(tournamentId) ?: badRequest("unknown tournament id")
|
||||
}
|
||||
|
||||
override fun get(request: HttpServletRequest): Json {
|
||||
val tournament = getTournament(request)
|
||||
val round = request.getParameter("round")?.toIntOrNull() ?: badRequest("invalid round number")
|
||||
return Json.Object();
|
||||
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
|
||||
return tournament.pairables.values.filter { !it.skip.contains(round) }.map { it.toJson() }.toJsonArray()
|
||||
}
|
||||
}
|
||||
|
||||
override fun post(request: HttpServletRequest): Json {
|
||||
val tournament = getTournament(request)
|
||||
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
|
||||
val payload = getArrayPayload(request)
|
||||
val allPlayers = payload.size == 1 && payload[0] == "all"
|
||||
if (!allPlayers && tournament.pairing.type == Pairing.PairingType.SWISS) badRequest("Swiss pairing requires all pairable players")
|
||||
val pairables =
|
||||
if (allPlayers)
|
||||
tournament.pairables.values.filter { !it.skip.contains(round) }
|
||||
else payload.map {
|
||||
if (it is Number) it.toInt() else badRequest("invalid pairable id: #$it")
|
||||
}.map { id ->
|
||||
tournament.pairables[id]?.also {
|
||||
if (it.skip.contains(round)) badRequest("pairable #$id does not play round $round")
|
||||
} ?: badRequest("invalid pairable id: #$id")
|
||||
}
|
||||
// check players are not already implied in games
|
||||
val pairablesIDs = pairables.map { it.id }.toSet()
|
||||
tournament.games.getOrNull(round)?.let { games ->
|
||||
games.values.mapNotNull { game ->
|
||||
if (pairablesIDs.contains(game.black)) game.black else if (pairablesIDs.contains(game.white)) game.white else null
|
||||
}.let {
|
||||
if (it.isNotEmpty()) badRequest("The following players are already playing this round: ${it.joinToString { id -> "#${id}" }}")
|
||||
}
|
||||
}
|
||||
val games = tournament.pair(round, pairables)
|
||||
return games.map { it.toJson() }.toJsonArray()
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package org.jeudego.pairgoth.api
|
||||
|
||||
import com.republicate.kson.Json
|
||||
import com.republicate.kson.toJsonArray
|
||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||
import org.jeudego.pairgoth.model.Player
|
||||
import org.jeudego.pairgoth.model.fromJson
|
||||
@@ -11,7 +12,7 @@ object PlayerHandler: PairgothApiHandler {
|
||||
override fun get(request: HttpServletRequest): Json {
|
||||
val tournament = getTournament(request) ?: badRequest("invalid tournament")
|
||||
return when (val pid = getSubSelector(request)?.toIntOrNull()) {
|
||||
null -> Json.Array(tournament.pairables.values.map { it.toJson() })
|
||||
null -> tournament.pairables.values.map { it.toJson() }.toJsonArray()
|
||||
else -> tournament.pairables[pid]?.toJson() ?: badRequest("no player with id #${pid}")
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,23 @@
|
||||
package org.jeudego.pairgoth.model
|
||||
|
||||
import org.jeudego.pairgoth.store.Store
|
||||
import com.republicate.kson.Json
|
||||
import org.jeudego.pairgoth.model.Game.Result.*
|
||||
|
||||
data class Game(val white: Int, val black: Int, var result: Char = '?', val id: Int = Store.nextGameId)
|
||||
data class Game(
|
||||
val id: Int,
|
||||
val white: Int,
|
||||
val black: Int,
|
||||
val handicap: Int = 0,
|
||||
var result: Result = UNKNOWN
|
||||
) {
|
||||
enum class Result(val symbol: Char) { UNKNOWN('?'), BLACK('b'), WHITE('w'), JIGO('='), CANCELLED('x') }
|
||||
}
|
||||
|
||||
// serialization
|
||||
|
||||
fun Game.toJson() = Json.Object(
|
||||
"id" to id,
|
||||
"w" to white,
|
||||
"b" to black,
|
||||
"r" to "${result.symbol}"
|
||||
)
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package org.jeudego.pairgoth.model
|
||||
|
||||
import com.republicate.kson.Json
|
||||
import com.republicate.kson.toJsonArray
|
||||
import org.jeudego.pairgoth.api.ApiHandler
|
||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||
import org.jeudego.pairgoth.store.Store
|
||||
@@ -13,6 +14,12 @@ sealed class Pairable(val id: Int, val name: String, open val rating: Int, open
|
||||
val skip = mutableSetOf<Int>() // skipped rounds
|
||||
}
|
||||
|
||||
object ByePlayer: Pairable(0, "bye", 0, Int.MIN_VALUE) {
|
||||
override fun toJson(): Json.Object {
|
||||
throw Error("bye player should never be serialized")
|
||||
}
|
||||
}
|
||||
|
||||
fun Pairable.displayRank(): String = when {
|
||||
rank < 0 -> "${-rank}k"
|
||||
rank >= 0 && rank < 10 -> "${rank + 1}d"
|
||||
@@ -86,7 +93,7 @@ class Team(id: Int, name: String): Pairable(id, name, 0, 0) {
|
||||
override fun toJson() = Json.Object(
|
||||
"id" to id,
|
||||
"name" to name,
|
||||
"players" to Json.Array(players.map { it.toJson() })
|
||||
"players" to players.map { it.toJson() }.toJsonArray()
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -3,12 +3,21 @@ package org.jeudego.pairgoth.model
|
||||
import com.republicate.kson.Json
|
||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||
import org.jeudego.pairgoth.model.Pairing.PairingType.*
|
||||
import org.jeudego.pairgoth.model.MacMahon
|
||||
import org.jeudego.pairgoth.model.RoundRobin
|
||||
import org.jeudego.pairgoth.model.Swiss
|
||||
import org.jeudego.pairgoth.pairing.SwissSolver
|
||||
import java.util.Random
|
||||
|
||||
// TODO - this is only an early draft
|
||||
|
||||
sealed class Pairing(val type: PairingType) {
|
||||
companion object {}
|
||||
companion object {
|
||||
val rand = Random(/* seed from properties - TODO */)
|
||||
}
|
||||
enum class PairingType { SWISS, MACMAHON, ROUNDROBIN }
|
||||
|
||||
abstract fun pair(tournament: Tournament, round: Int, pairables: List<Pairable>): List<Game>
|
||||
}
|
||||
|
||||
class Swiss(
|
||||
@@ -16,6 +25,13 @@ class Swiss(
|
||||
var firstRoundMethod: Method = method
|
||||
): Pairing(SWISS) {
|
||||
enum class Method { FOLD, RANDOM, SLIP }
|
||||
override fun pair(tournament: Tournament, round: Int, pairables: List<Pairable>): List<Game> {
|
||||
val actualMethod = if (round == 1) firstRoundMethod else method
|
||||
val history =
|
||||
if (tournament.games.isEmpty()) emptyList()
|
||||
else tournament.games.slice(0 until round).flatMap { it.values }
|
||||
return SwissSolver(history, actualMethod).pair(pairables)
|
||||
}
|
||||
}
|
||||
|
||||
class MacMahon(
|
||||
@@ -23,9 +39,17 @@ class MacMahon(
|
||||
var minLevel: Int = -30
|
||||
): Pairing(MACMAHON) {
|
||||
val groups = mutableListOf<Int>()
|
||||
|
||||
override fun pair(tournament: Tournament, round: Int, pairables: List<Pairable>): List<Game> {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
|
||||
class RoundRobin: Pairing(ROUNDROBIN)
|
||||
class RoundRobin: Pairing(ROUNDROBIN) {
|
||||
override fun pair(tournament: Tournament, round: Int, pairables: List<Pairable>): List<Game> {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
|
||||
// Serialization
|
||||
|
||||
@@ -46,3 +70,4 @@ fun Pairing.toJson() = when (this) {
|
||||
is MacMahon -> Json.Object("type" to type.name, "bar" to bar, "minLevel" to minLevel)
|
||||
is RoundRobin -> Json.Object("type" to type.name)
|
||||
}
|
||||
|
||||
|
@@ -2,7 +2,6 @@ package org.jeudego.pairgoth.model
|
||||
|
||||
import com.republicate.kson.Json
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.jeudego.pairgoth.api.ApiHandler
|
||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||
import org.jeudego.pairgoth.store.Store
|
||||
|
||||
@@ -38,10 +37,21 @@ data class Tournament(
|
||||
NBW, MMS, SOS, SOSOS, SODOS
|
||||
}
|
||||
|
||||
// pairables
|
||||
// pairables per id
|
||||
val pairables = mutableMapOf<Int, Pairable>()
|
||||
|
||||
// games per round
|
||||
// pairing
|
||||
fun pair(round: Int, pairables: List<Pairable>): List<Game> {
|
||||
// Minimal check on round number.
|
||||
// CB TODO - the complete check should verify, for each player, that he was either non pairable or implied in the previous round
|
||||
if (round > games.size + 1) badRequest("previous round not paired")
|
||||
val evenPairables =
|
||||
if (pairables.size % 2 == 0) pairables
|
||||
else pairables.toMutableList()?.also { it.add(ByePlayer) }
|
||||
return pairing.pair(this, round, evenPairables)
|
||||
}
|
||||
|
||||
// games per id for each round
|
||||
val games = mutableListOf<MutableMap<Int, Game>>()
|
||||
|
||||
// standings criteria
|
||||
|
@@ -0,0 +1,98 @@
|
||||
package org.jeudego.pairgoth.pairing
|
||||
|
||||
import org.jeudego.pairgoth.model.Game
|
||||
import org.jeudego.pairgoth.model.Game.Result.*
|
||||
import org.jeudego.pairgoth.model.Pairable
|
||||
import org.jeudego.pairgoth.store.Store
|
||||
import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedPerfectMatching
|
||||
import org.jgrapht.alg.matching.blossom.v5.ObjectiveSense
|
||||
import org.jgrapht.graph.DefaultWeightedEdge
|
||||
import org.jgrapht.graph.SimpleWeightedGraph
|
||||
import org.jgrapht.graph.builder.GraphBuilder
|
||||
|
||||
sealed class Solver(private val history: List<Game>) {
|
||||
|
||||
open fun sort(p: Pairable, q: Pairable): Int = 0 // no sort by default
|
||||
abstract fun weight(p: Pairable, q: Pairable): Double
|
||||
|
||||
fun pair(pairables: List<Pairable>): List<Game> {
|
||||
// check that at this stage, we have an even number of pairables
|
||||
if (pairables.size % 2 != 0) throw Error("expecting an even number of pairables")
|
||||
val sorted = pairables.sortedWith(::sort)
|
||||
val builder = GraphBuilder(SimpleWeightedGraph<Pairable, DefaultWeightedEdge>(DefaultWeightedEdge::class.java))
|
||||
for (i in sorted.indices) {
|
||||
for (j in i + 1 until sorted.size) {
|
||||
val p = pairables[i]
|
||||
val q = pairables[j]
|
||||
builder.addEdge(p, q, weight(p, q))
|
||||
}
|
||||
}
|
||||
val graph = builder.build()
|
||||
val matching = KolmogorovWeightedPerfectMatching(graph, ObjectiveSense.MINIMIZE)
|
||||
val solution = matching.matching
|
||||
|
||||
val result = solution.map {
|
||||
Game(Store.nextGameId, graph.getEdgeSource(it).id , graph.getEdgeTarget(it).id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// already paired players map
|
||||
fun Pairable.played(other: Pairable) = _paired.contains(Pair(id, other.id))
|
||||
private val _paired: Set<Pair<Int, Int>> by lazy {
|
||||
(history.map { game ->
|
||||
Pair(game.black, game.white)
|
||||
} + history.map { game ->
|
||||
Pair(game.white, game.black)
|
||||
}).toSet()
|
||||
}
|
||||
|
||||
// score (number of wins)
|
||||
val Pairable.score: Int get() = _score[id] ?: 0
|
||||
private val _score: Map<Int, Int> by lazy {
|
||||
history.mapNotNull { game ->
|
||||
when (game.result) {
|
||||
BLACK -> game.black
|
||||
WHITE -> game.white
|
||||
else -> null
|
||||
}
|
||||
}.groupingBy { it }.eachCount()
|
||||
}
|
||||
|
||||
// sos
|
||||
val Pairable.sos: Int get() = _sos[id] ?: 0
|
||||
private val _sos by lazy {
|
||||
(history.map { game ->
|
||||
Pair(game.black, _score[game.white] ?: 0)
|
||||
} + history.map { game ->
|
||||
Pair(game.white, _score[game.black] ?: 0)
|
||||
}).groupingBy { it.first }.fold(0) { acc, next ->
|
||||
acc + next.second
|
||||
}
|
||||
}
|
||||
|
||||
// sosos
|
||||
val Pairable.sosos: Int get() = _sosos[id] ?: 0
|
||||
private val _sosos by lazy {
|
||||
(history.map { game ->
|
||||
Pair(game.black, _sos[game.white] ?: 0)
|
||||
} + history.map { game ->
|
||||
Pair(game.white, _sos[game.black] ?: 0)
|
||||
}).groupingBy { it.first }.fold(0) { acc, next ->
|
||||
acc + next.second
|
||||
}
|
||||
}
|
||||
|
||||
// sodos
|
||||
val Pairable.sodos: Int get() = _sodos[id] ?: 0
|
||||
private val _sodos by lazy {
|
||||
(history.map { game ->
|
||||
Pair(game.black, if (game.result == BLACK) _score[game.white] ?: 0 else 0)
|
||||
} + history.map { game ->
|
||||
Pair(game.white, if (game.result == WHITE) _score[game.black] ?: 0 else 0)
|
||||
}).groupingBy { it.first }.fold(0) { acc, next ->
|
||||
acc + next.second
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
package org.jeudego.pairgoth.pairing
|
||||
|
||||
import org.jeudego.pairgoth.model.Game
|
||||
import org.jeudego.pairgoth.model.Pairable
|
||||
import org.jeudego.pairgoth.model.Swiss
|
||||
import kotlin.math.abs
|
||||
|
||||
class SwissSolver(history: List<Game>, method: Swiss.Method): Solver(history) {
|
||||
|
||||
override fun sort(p: Pairable, q: Pairable): Int =
|
||||
when (p.score) {
|
||||
q.score -> p.rating - q.rating
|
||||
else -> p.score - q.score
|
||||
}
|
||||
|
||||
override fun weight(p: Pairable, q: Pairable) = when {
|
||||
p.played(q) -> 100_000.0
|
||||
p.score != q.score -> abs(p.score - q.score) * 10_000.0
|
||||
else -> abs(p.rating - q.rating) * 10.0
|
||||
}
|
||||
}
|
@@ -41,6 +41,15 @@ class BasicTests: TestBase() {
|
||||
"club" to "13Ma"
|
||||
)
|
||||
|
||||
val anotherPlayer = Json.Object(
|
||||
"name" to "Poirot",
|
||||
"firstname" to "Hercule",
|
||||
"rating" to 1700,
|
||||
"rank" to -1,
|
||||
"country" to "FR",
|
||||
"club" to "75Op"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `001 create tournament`() {
|
||||
val resp = TestAPI.post("/api/tour", aTournament) as Json.Object
|
||||
@@ -75,4 +84,14 @@ class BasicTests: TestBase() {
|
||||
val player = TestAPI.get("/api/tour/1/part/1") as Json.Object
|
||||
assertEquals("[1]", player.getArray("skip").toString(), "First player should have id #1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `005 pair`() {
|
||||
val resp = TestAPI.post("/api/tour/1/part", anotherPlayer) as Json.Object
|
||||
assertTrue(resp.getBoolean("success") == true, "expecting success")
|
||||
val pairable = TestAPI.get("/api/tour/1/pair/1")
|
||||
logger.info(pairable.toString())
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
Reference in New Issue
Block a user