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()) + } + + }