Swiss pairing in progress

This commit is contained in:
Claude Brisson
2023-05-17 21:49:21 +02:00
parent 100d28e483
commit 67fc65dada
11 changed files with 263 additions and 16 deletions

14
pom.xml
View File

@@ -11,13 +11,23 @@
<!-- CB: Temporary add my repository, while waiting for SSE Java server module author to incorporate my PR or for me to fork it --> <!-- CB: Temporary add my repository, while waiting for SSE Java server module author to incorporate my PR or for me to fork it -->
<repositories> <repositories>
<repository> <repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository> <repository>
<id>republicate.com</id> <id>republicate.com</id>
<url>https://republicate.com/maven2</url> <url>https://republicate.com/maven2</url>
<releases> <releases>
<enabled>true</enabled> <enabled>true</enabled>
<updatePolicy>always</updatePolicy>
<checksumPolicy>fail</checksumPolicy>
</releases> </releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository> </repository>
</repositories> </repositories>

View File

@@ -251,6 +251,12 @@
<artifactId>jeasse-servlet3</artifactId> <artifactId>jeasse-servlet3</artifactId>
<version>1.2</version> <version>1.2</version>
</dependency> </dependency>
<!-- graph solver -->
<dependency>
<groupId>org.jgrapht</groupId>
<artifactId>jgrapht-core</artifactId>
<version>1.5.2</version>
</dependency>
<!-- tests --> <!-- tests -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>

View File

@@ -1,21 +1,53 @@
package org.jeudego.pairgoth.api package org.jeudego.pairgoth.api
import com.republicate.kson.Json import com.republicate.kson.Json
import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest 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.Tournament
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.store.Store import org.jeudego.pairgoth.store.Store
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
object PairingHandler: ApiHandler { object PairingHandler: ApiHandler {
private fun getTournament(request: HttpServletRequest): Tournament { private fun getTournament(request: HttpServletRequest): Tournament {
val tournamentId = getSelector(request)?.toIntOrNull() ?: ApiHandler.badRequest("invalid tournament id") val tournamentId = getSelector(request)?.toIntOrNull() ?: badRequest("invalid tournament id")
return Store.getTournament(tournamentId) ?: ApiHandler.badRequest("unknown tournament id") return Store.getTournament(tournamentId) ?: badRequest("unknown tournament id")
} }
override fun get(request: HttpServletRequest): Json { override fun get(request: HttpServletRequest): Json {
val tournament = getTournament(request) val tournament = getTournament(request)
val round = request.getParameter("round")?.toIntOrNull() ?: badRequest("invalid round number") val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
return Json.Object(); 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()
} }
} }

View File

@@ -1,6 +1,7 @@
package org.jeudego.pairgoth.api package org.jeudego.pairgoth.api
import com.republicate.kson.Json import com.republicate.kson.Json
import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.Player import org.jeudego.pairgoth.model.Player
import org.jeudego.pairgoth.model.fromJson import org.jeudego.pairgoth.model.fromJson
@@ -11,7 +12,7 @@ object PlayerHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest): Json { override fun get(request: HttpServletRequest): Json {
val tournament = getTournament(request) ?: badRequest("invalid tournament") val tournament = getTournament(request) ?: badRequest("invalid tournament")
return when (val pid = getSubSelector(request)?.toIntOrNull()) { 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}") else -> tournament.pairables[pid]?.toJson() ?: badRequest("no player with id #${pid}")
} }
} }

View File

@@ -1,5 +1,23 @@
package org.jeudego.pairgoth.model 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}"
)

View File

@@ -1,6 +1,7 @@
package org.jeudego.pairgoth.model package org.jeudego.pairgoth.model
import com.republicate.kson.Json import com.republicate.kson.Json
import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.api.ApiHandler import org.jeudego.pairgoth.api.ApiHandler
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.store.Store 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 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 { fun Pairable.displayRank(): String = when {
rank < 0 -> "${-rank}k" rank < 0 -> "${-rank}k"
rank >= 0 && rank < 10 -> "${rank + 1}d" 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( override fun toJson() = Json.Object(
"id" to id, "id" to id,
"name" to name, "name" to name,
"players" to Json.Array(players.map { it.toJson() }) "players" to players.map { it.toJson() }.toJsonArray()
) )
} }

View File

@@ -3,12 +3,21 @@ package org.jeudego.pairgoth.model
import com.republicate.kson.Json import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.Pairing.PairingType.* 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 // TODO - this is only an early draft
sealed class Pairing(val type: PairingType) { sealed class Pairing(val type: PairingType) {
companion object {} companion object {
val rand = Random(/* seed from properties - TODO */)
}
enum class PairingType { SWISS, MACMAHON, ROUNDROBIN } enum class PairingType { SWISS, MACMAHON, ROUNDROBIN }
abstract fun pair(tournament: Tournament, round: Int, pairables: List<Pairable>): List<Game>
} }
class Swiss( class Swiss(
@@ -16,6 +25,13 @@ class Swiss(
var firstRoundMethod: Method = method var firstRoundMethod: Method = method
): Pairing(SWISS) { ): Pairing(SWISS) {
enum class Method { FOLD, RANDOM, SLIP } 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( class MacMahon(
@@ -23,9 +39,17 @@ class MacMahon(
var minLevel: Int = -30 var minLevel: Int = -30
): Pairing(MACMAHON) { ): Pairing(MACMAHON) {
val groups = mutableListOf<Int>() 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 // 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 MacMahon -> Json.Object("type" to type.name, "bar" to bar, "minLevel" to minLevel)
is RoundRobin -> Json.Object("type" to type.name) is RoundRobin -> Json.Object("type" to type.name)
} }

View File

@@ -2,7 +2,6 @@ package org.jeudego.pairgoth.model
import com.republicate.kson.Json import com.republicate.kson.Json
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import org.jeudego.pairgoth.api.ApiHandler
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.store.Store import org.jeudego.pairgoth.store.Store
@@ -38,10 +37,21 @@ data class Tournament(
NBW, MMS, SOS, SOSOS, SODOS NBW, MMS, SOS, SOSOS, SODOS
} }
// pairables // pairables per id
val pairables = mutableMapOf<Int, Pairable>() 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>>() val games = mutableListOf<MutableMap<Int, Game>>()
// standings criteria // standings criteria

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -41,6 +41,15 @@ class BasicTests: TestBase() {
"club" to "13Ma" "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 @Test
fun `001 create tournament`() { fun `001 create tournament`() {
val resp = TestAPI.post("/api/tour", aTournament) as Json.Object 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 val player = TestAPI.get("/api/tour/1/part/1") as Json.Object
assertEquals("[1]", player.getArray("skip").toString(), "First player should have id #1") 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())
}
} }