diff --git a/pom.xml b/pom.xml
index 3d17b69..7faafe3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,13 +11,23 @@
+ central
+ https://repo.maven.apache.org/maven2
+
+ true
+
+
+ true
+
+
republicate.com
https://republicate.com/maven2
true
- always
- fail
+
+ true
+
diff --git a/webapp/pom.xml b/webapp/pom.xml
index 6a09c33..0270a8c 100644
--- a/webapp/pom.xml
+++ b/webapp/pom.xml
@@ -251,6 +251,12 @@
jeasse-servlet3
1.2
+
+
+ org.jgrapht
+ jgrapht-core
+ 1.5.2
+
org.junit.jupiter
diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt
index 1e99ef5..526eb0d 100644
--- a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt
+++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt
@@ -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()
}
-}
\ No newline at end of file
+
+ 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()
+ }
+}
diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt
index f92370d..dd8d0d9 100644
--- a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt
+++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt
@@ -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}")
}
}
diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Game.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Game.kt
index cdc149f..6d6b6a3 100644
--- a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Game.kt
+++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Game.kt
@@ -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}"
+)
diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt
index 1cad609..a3523b6 100644
--- a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt
+++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairable.kt
@@ -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() // 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()
)
}
diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt
index 6473bba..1bb5c43 100644
--- a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt
+++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt
@@ -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): List
}
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): List {
+ 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()
+
+ override fun pair(tournament: Tournament, round: Int, pairables: List): List {
+ TODO()
+ }
}
-class RoundRobin: Pairing(ROUNDROBIN)
+class RoundRobin: Pairing(ROUNDROBIN) {
+ override fun pair(tournament: Tournament, round: Int, pairables: List): List {
+ 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)
}
+
diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt
index 7d61cbb..66ef5ee 100644
--- a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt
+++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt
@@ -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()
- // games per round
+ // pairing
+ fun pair(round: Int, pairables: List): List {
+ // 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>()
// standings criteria
diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/Solver.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/Solver.kt
new file mode 100644
index 0000000..8ba0bc2
--- /dev/null
+++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/Solver.kt
@@ -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) {
+
+ 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): List {
+ // 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(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> 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 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
+ }
+ }
+
+}
diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/SwissSolver.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/SwissSolver.kt
new file mode 100644
index 0000000..9af0f89
--- /dev/null
+++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/SwissSolver.kt
@@ -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, 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
+ }
+}
diff --git a/webapp/src/test/kotlin/BasicTests.kt b/webapp/src/test/kotlin/BasicTests.kt
index 6187225..0405c2f 100644
--- a/webapp/src/test/kotlin/BasicTests.kt
+++ b/webapp/src/test/kotlin/BasicTests.kt
@@ -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())
+ }
+
+
}