Team handling debugging; basic MacMahon implementation

This commit is contained in:
Claude Brisson
2023-05-23 16:19:56 +02:00
parent cca9752f20
commit 74dcb64899
12 changed files with 219 additions and 94 deletions

View File

@@ -15,7 +15,7 @@ object PairingHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? { override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
val tournament = getTournament(request) val tournament = getTournament(request)
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number") val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
val playing = (tournament.games.getOrNull(round)?.values ?: emptyList()).flatMap { val playing = tournament.games(round).values.flatMap {
listOf(it.black, it.white) listOf(it.black, it.white)
}.toSet() }.toSet()
return tournament.pairables.values.filter { !it.skip.contains(round) && !playing.contains(it.id) }.map { it.id }.toJsonArray() return tournament.pairables.values.filter { !it.skip.contains(round) && !playing.contains(it.id) }.map { it.id }.toJsonArray()
@@ -27,7 +27,7 @@ object PairingHandler: PairgothApiHandler {
val payload = getArrayPayload(request) val payload = getArrayPayload(request)
val allPlayers = payload.size == 1 && payload[0] == "all" val allPlayers = payload.size == 1 && payload[0] == "all"
if (!allPlayers && tournament.pairing.type == Pairing.PairingType.SWISS) badRequest("Swiss pairing requires all pairable players") if (!allPlayers && tournament.pairing.type == Pairing.PairingType.SWISS) badRequest("Swiss pairing requires all pairable players")
val playing = (tournament.games.getOrNull(round)?.values ?: emptyList()).flatMap { val playing = (tournament.games(round).values).flatMap {
listOf(it.black, it.white) listOf(it.black, it.white)
}.toSet() }.toSet()
val pairables = val pairables =
@@ -52,15 +52,15 @@ object PairingHandler: PairgothApiHandler {
val tournament = getTournament(request) val tournament = getTournament(request)
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number") val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
// only allow last round (if players have not been paired in the last round, it *may* be possible to be more laxist...) // only allow last round (if players have not been paired in the last round, it *may* be possible to be more laxist...)
if (round != tournament.games.size) badRequest("cannot delete games in other rounds but the last") if (round != tournament.lastRound()) badRequest("cannot delete games in other rounds but the last")
val payload = getArrayPayload(request) val payload = getArrayPayload(request)
val allPlayers = payload.size == 1 && payload[0] == "all" val allPlayers = payload.size == 1 && payload[0] == "all"
if (allPlayers) { if (allPlayers) {
tournament.games.removeLast() tournament.games(round).clear()
} else { } else {
payload.forEach { payload.forEach {
val id = (it as Number).toInt() val id = (it as Number).toInt()
tournament.games[round].remove(id) tournament.games(round).remove(id)
} }
} }
Event.dispatch(gamesDeleted, Json.Object("tournament" to tournament.id, "round" to round, "data" to payload)) Event.dispatch(gamesDeleted, Json.Object("tournament" to tournament.id, "round" to round, "data" to payload))

View File

@@ -14,7 +14,7 @@ object ResultsHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? { override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
val tournament = getTournament(request) val tournament = getTournament(request)
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number") val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
val games = tournament.games.getOrNull(round)?.values ?: emptyList() val games = tournament.games(round).values
return games.map { it.toJson() }.toJsonArray() return games.map { it.toJson() }.toJsonArray()
} }
@@ -22,8 +22,8 @@ object ResultsHandler: PairgothApiHandler {
val tournament = getTournament(request) val tournament = getTournament(request)
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number") val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
val payload = getObjectPayload(request) val payload = getObjectPayload(request)
val game = tournament.games[round][payload.getInt("id")] ?: badRequest("invalid game id") val game = tournament.games(round)[payload.getInt("id")] ?: badRequest("invalid game id")
game.result = Game.Result.valueOf(payload.getString("result") ?: badRequest("missing result")) game.result = Game.Result.fromSymbol(payload.getChar("result") ?: badRequest("missing result"))
Event.dispatch(Event.resultUpdated, Json.Object("tournament" to tournament.id, "round" to round, "data" to game)) Event.dispatch(Event.resultUpdated, Json.Object("tournament" to tournament.id, "round" to round, "data" to game))
return Json.Object("success" to true) return Json.Object("success" to true)
} }

View File

@@ -54,11 +54,15 @@ object TournamentHandler: PairgothApiHandler {
// disallow changing type // disallow changing type
if (payload.getString("type")?.let { it != tournament.type.name } == true) badRequest("tournament type cannot be changed") if (payload.getString("type")?.let { it != tournament.type.name } == true) badRequest("tournament type cannot be changed")
val updated = Tournament.fromJson(payload, tournament) val updated = Tournament.fromJson(payload, tournament)
// copy players, games, criteria (this copy should be provided by the Tournament class - CB TODO)
updated.players.putAll(tournament.players) updated.players.putAll(tournament.players)
if (tournament is TeamTournament && updated is TeamTournament) { if (tournament is TeamTournament && updated is TeamTournament) {
updated.teams.putAll(tournament.teams) updated.teams.putAll(tournament.teams)
} }
updated.games.addAll(tournament.games) for (round in 1..tournament.lastRound()) updated.games(round).apply {
clear()
putAll(tournament.games(round))
}
updated.criteria.addAll(tournament.criteria) updated.criteria.addAll(tournament.criteria)
Store.replaceTournament(updated) Store.replaceTournament(updated)
Event.dispatch(tournamentUpdated, tournament.toJson()) Event.dispatch(tournamentUpdated, tournament.toJson())

View File

@@ -173,7 +173,9 @@ object OpenGotha {
) )
}.associateBy { it.id }.toMutableMap() }.associateBy { it.id }.toMutableMap()
} }
tournament.games.addAll(gamesPerRound) gamesPerRound.forEachIndexed { index, games ->
tournament.games(index).putAll(games)
}
return tournament return tournament
} }
@@ -209,9 +211,9 @@ object OpenGotha {
} }
</Players> </Players>
<Games> <Games>
${tournament.games.flatMapIndexed { round, games -> ${(1..tournament.lastRound()).map { tournament.games(it) }.flatMapIndexed { index, games ->
games.values.mapIndexed { table, game -> games.values.mapIndexed { table, game ->
Triple(round, table , game) Triple(index + 1, table , game)
} }
}.joinToString("\n") { (round, table, game) -> }.joinToString("\n") { (round, table, game) ->
"""<Game blackPlayer="${ """<Game blackPlayer="${
@@ -228,7 +230,7 @@ object OpenGotha {
Game.Result.BOTHLOOSE -> "RESULT_BOTHLOOSE" Game.Result.BOTHLOOSE -> "RESULT_BOTHLOOSE"
} }
}" roundNumber="${ }" roundNumber="${
round + 1 round
}" tableNumber="${ }" tableNumber="${
table + 1 table + 1
}" whitePlayer="${ }" whitePlayer="${

View File

@@ -2,6 +2,7 @@ package org.jeudego.pairgoth.model
import com.republicate.kson.Json import com.republicate.kson.Json
import org.jeudego.pairgoth.model.Game.Result.* import org.jeudego.pairgoth.model.Game.Result.*
import java.util.*
data class Game( data class Game(
val id: Int, val id: Int,
@@ -10,7 +11,20 @@ data class Game(
val handicap: Int = 0, val handicap: Int = 0,
var result: Result = UNKNOWN var result: Result = UNKNOWN
) { ) {
enum class Result(val symbol: Char) { UNKNOWN('?'), BLACK('b'), WHITE('w'), JIGO('='), CANCELLED('x'), BOTHWIN('+'), BOTHLOOSE('-') } enum class Result(val symbol: Char) {
UNKNOWN('?'),
BLACK('b'),
WHITE('w'),
JIGO('='),
CANCELLED('X'),
BOTHWIN('#'),
BOTHLOOSE('0');
companion object {
private val byChar = Result.values().associateBy { it.symbol }
fun fromSymbol(c: Char) = byChar[c] ?: throw Error("unknown result symbol: $c")
}
}
} }
// serialization // serialization
@@ -19,5 +33,6 @@ fun Game.toJson() = Json.Object(
"id" to id, "id" to id,
"w" to white, "w" to white,
"b" to black, "b" to black,
"h" to handicap,
"r" to "${result.symbol}" "r" to "${result.symbol}"
) )

View File

@@ -12,6 +12,8 @@ import kotlin.math.roundToInt
sealed class Pairable(val id: Int, val name: String, open val rating: Int, open val rank: Int) { sealed class Pairable(val id: Int, val name: String, open val rating: Int, open val rank: Int) {
companion object {} companion object {}
abstract fun toJson(): Json.Object abstract fun toJson(): Json.Object
abstract val club: String?
abstract val country: String?
val skip = mutableSetOf<Int>() // skipped rounds val skip = mutableSetOf<Int>() // skipped rounds
} }
@@ -19,13 +21,15 @@ object ByePlayer: Pairable(0, "bye", 0, Int.MIN_VALUE) {
override fun toJson(): Json.Object { override fun toJson(): Json.Object {
throw Error("bye player should never be serialized") throw Error("bye player should never be serialized")
} }
override val club = "none"
override val country = "none"
} }
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 < 10 -> "${rank + 1}d"
rank >= 10 -> "${rank - 9}p" else -> "${rank - 9}p"
else -> throw Error("impossible")
} }
private val rankRegex = Regex("(\\d+)([kdp])", RegexOption.IGNORE_CASE) private val rankRegex = Regex("(\\d+)([kdp])", RegexOption.IGNORE_CASE)
@@ -50,8 +54,8 @@ class Player(
var firstname: String, var firstname: String,
rating: Int, rating: Int,
rank: Int, rank: Int,
var country: String, override var country: String,
var club: String override var club: String
): Pairable(id, name, rating, rank) { ): Pairable(id, name, rating, rank) {
companion object companion object
// used to store external IDs ("FFG" => FFG ID, "EGF" => EGF PIN, "AGA" => AGA ID ...) // used to store external IDs ("FFG" => FFG ID, "EGF" => EGF PIN, "AGA" => AGA ID ...)

View File

@@ -6,32 +6,39 @@ import org.jeudego.pairgoth.model.Pairing.PairingType.*
import org.jeudego.pairgoth.model.MacMahon import org.jeudego.pairgoth.model.MacMahon
import org.jeudego.pairgoth.model.RoundRobin import org.jeudego.pairgoth.model.RoundRobin
import org.jeudego.pairgoth.model.Swiss import org.jeudego.pairgoth.model.Swiss
import org.jeudego.pairgoth.pairing.MacMahonSolver
import org.jeudego.pairgoth.pairing.SwissSolver import org.jeudego.pairgoth.pairing.SwissSolver
import java.util.Random import java.util.Random
// TODO - this is only an early draft
sealed class Pairing(val type: PairingType, val weights: Weights = Weights()) { sealed class Pairing(val type: PairingType, val weights: Weights = Weights()) {
companion object {} companion object {}
enum class PairingType { SWISS, MACMAHON, ROUNDROBIN } enum class PairingType { SWISS, MACMAHON, ROUNDROBIN }
data class Weights( data class Weights(
val played: Double = 1_000_000.0, // weight if players already met val played: Double = 1_000_000.0, // players already met
val group: Double = 100_000.0, // different group
val handicap: Double = 50_000.0, // for each handicap stone
val score: Double = 10_000.0, // per difference of score or MMS val score: Double = 10_000.0, // per difference of score or MMS
val place: Double = 1_000.0, // per difference of expected position for Swiss val place: Double = 1_000.0, // per difference of expected position for Swiss
val color: Double = 100.0 // per color unbalancing val color: Double = 500.0, // per color unbalancing
val club: Double = 100.0, // same club weight
val country: Double = 50.0 // same country
) )
abstract fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> abstract fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game>
} }
fun Tournament<*>.historyBefore(round: Int) = fun Tournament<*>.historyBefore(round: Int) =
if (games.isEmpty()) emptyList() if (lastRound() == 0) emptyList()
else games.slice(0 until round).flatMap { it.values } else (0 until round).flatMap { games(round).values }
class Swiss( class Swiss(
var method: Method, var method: Method,
var firstRoundMethod: Method = method var firstRoundMethod: Method = method,
): Pairing(SWISS) { ): Pairing(SWISS, Weights(
handicap = 0.0, // no handicap games anyway
club = 0.0,
country = 0.0
)) {
enum class Method { SPLIT_AND_FOLD, SPLIT_AND_RANDOM, SPLIT_AND_SLIP } enum class Method { SPLIT_AND_FOLD, SPLIT_AND_RANDOM, SPLIT_AND_SLIP }
override fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> { override fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> {
val actualMethod = if (round == 1) firstRoundMethod else method val actualMethod = if (round == 1) firstRoundMethod else method
@@ -41,12 +48,13 @@ class Swiss(
class MacMahon( class MacMahon(
var bar: Int = 0, var bar: Int = 0,
var minLevel: Int = -30 var minLevel: Int = -30,
var reducer: Int = 1
): Pairing(MACMAHON) { ): Pairing(MACMAHON) {
val groups = mutableListOf<Int>() val groups = mutableListOf<Int>()
override fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> { override fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> {
TODO() return MacMahonSolver(tournament.historyBefore(round), pairables, weights, mmBase = minLevel, mmBar = bar, reducer = reducer).pair()
} }
} }
@@ -65,7 +73,8 @@ fun Pairing.Companion.fromJson(json: Json.Object) = when (json.getString("type")
) )
MACMAHON -> MacMahon( MACMAHON -> MacMahon(
bar = json.getInt("bar") ?: 0, bar = json.getInt("bar") ?: 0,
minLevel = json.getInt("minLevel") ?: -30 minLevel = json.getInt("minLevel") ?: -30,
reducer = json.getInt("reducer") ?: 1
) )
ROUNDROBIN -> RoundRobin() ROUNDROBIN -> RoundRobin()
} }
@@ -74,7 +83,7 @@ fun Pairing.toJson() = when (this) {
is Swiss -> is Swiss ->
if (method == firstRoundMethod) Json.Object("type" to type.name, "method" to method.name) if (method == firstRoundMethod) Json.Object("type" to type.name, "method" to method.name)
else Json.Object("type" to type.name, "method" to method.name, "firstRoundMethod" to firstRoundMethod.name) else Json.Object("type" to type.name, "method" to method.name, "firstRoundMethod" to firstRoundMethod.name)
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, "reducer" to reducer)
is RoundRobin -> Json.Object("type" to type.name) is RoundRobin -> Json.Object("type" to type.name)
} }

View File

@@ -56,11 +56,17 @@ sealed class Tournament <P: Pairable>(
val evenPairables = val evenPairables =
if (pairables.size % 2 == 0) pairables if (pairables.size % 2 == 0) pairables
else pairables.toMutableList().also { it.add(ByePlayer) } else pairables.toMutableList().also { it.add(ByePlayer) }
return pairing.pair(this, round, evenPairables) return pairing.pair(this, round, evenPairables).also { newGames ->
if (games.size < round) games.add(mutableMapOf())
games[round - 1].putAll( newGames.associateBy { it.id } )
}
} }
// games per id for each round // games per id for each round
val games = mutableListOf<MutableMap<Int, Game>>() private val games = mutableListOf<MutableMap<Int, Game>>()
fun games(round: Int) = games.getOrNull(round - 1) ?: mutableMapOf()
fun lastRound() = games.size
// standings criteria // standings criteria
val criteria = mutableListOf<Criterion>( val criteria = mutableListOf<Criterion>(
@@ -116,10 +122,10 @@ class TeamTournament(
inner class Team(id: Int, name: String): Pairable(id, name, 0, 0) { inner class Team(id: Int, name: String): Pairable(id, name, 0, 0) {
val playerIds = mutableSetOf<Int>() val playerIds = mutableSetOf<Int>()
val teamPlayers: Set<Player> get() = playerIds.mapNotNull { players[id] }.toSet() val teamPlayers: Set<Player> get() = playerIds.mapNotNull { players[id] }.toSet()
override val rating: Int get() = if (players.isEmpty()) super.rating else (teamPlayers.sumOf { player -> player.rating.toDouble() } / players.size).roundToInt() override val rating: Int get() = if (teamPlayers.isEmpty()) super.rating else (teamPlayers.sumOf { player -> player.rating.toDouble() } / players.size).roundToInt()
override val rank: Int get() = if (players.isEmpty()) super.rank else (teamPlayers.sumOf { player -> player.rank.toDouble() } / players.size).roundToInt() override val rank: Int get() = if (teamPlayers.isEmpty()) super.rank else (teamPlayers.sumOf { player -> player.rank.toDouble() } / players.size).roundToInt()
val club: String? get() = players.map { club }.distinct().let { if (it.size == 1) it[0] else null } override val club: String? get() = teamPlayers.map { club }.distinct().let { if (it.size == 1) it[0] else null }
val country: String? get() = players.map { country }.distinct().let { if (it.size == 1) it[0] else null } override val country: String? get() = teamPlayers.map { country }.distinct().let { if (it.size == 1) it[0] else null }
override fun toJson() = Json.Object( override fun toJson() = Json.Object(
"id" to id, "id" to id,
"name" to name, "name" to name,

View File

@@ -0,0 +1,45 @@
package org.jeudego.pairgoth.pairing
import org.jeudego.pairgoth.model.Game
import org.jeudego.pairgoth.model.Pairable
import org.jeudego.pairgoth.model.Pairing
import org.jeudego.pairgoth.model.Swiss.Method.*
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sign
class MacMahonSolver(history: List<Game>, pairables: List<Pairable>, weights: Pairing.Weights, val mmBase: Int, val mmBar: Int, val reducer: Int): Solver(history, pairables, weights) {
val Pairable.mms get() = mmBase + score
// CB TODO - configurable criteria
override fun sort(p: Pairable, q: Pairable): Int =
if (p.mms != q.mms) ((q.mms - p.mms) * 1000).toInt()
else if (p.sos != q.sos) ((q.sos - p.sos) * 1000).toInt()
else if (p.sosos != q.sosos) ((q.sosos - p.sosos) * 1000).toInt()
else 0
override fun weight(black: Pairable, white: Pairable): Double {
var weight = 0.0
if (black.played(white)) weight += weights.played
if (black.club == white.club) weight += weights.club
if (black.country == white.country) weight += weights.country
weight += (abs(black.colorBalance + 1) + abs(white.colorBalance - 1)) * weights.color
// MacMahon specific
weight += Math.abs(black.mms - white.mms) * weights.score
if (sign(mmBar - black.mms) != sign(mmBar - white.mms)) weight += weights.group
if (black.mms < mmBar && white.mms < mmBar && abs(black.mms - white.mms) > reducer) {
if (black.mms > white.mms) weight = Double.NaN
else weight = handicap(black, white) * weights.handicap
}
return weight
}
override fun handicap(black: Pairable, white: Pairable) =
if (black.mms > mmBar || white.mms > mmBar || abs(black.mms - white.mms) < reducer || black.mms > white.mms) 0
else (white.mms - black.mms - reducer).roundToInt()
}

View File

@@ -20,7 +20,12 @@ sealed class Solver(val history: List<Game>, val pairables: List<Pairable>, val
} }
open fun sort(p: Pairable, q: Pairable): Int = 0 // no sort by default open fun sort(p: Pairable, q: Pairable): Int = 0 // no sort by default
abstract fun weight(p: Pairable, q: Pairable): Double abstract fun weight(black: Pairable, white: Pairable): Double
open fun handicap(black: Pairable, white: Pairable) = 0
open fun games(black: Pairable, white: Pairable): List<Game> {
// CB TODO team of individuals pairing
return listOf(Game(id = Store.nextGameId, black = black.id, white = white.id, handicap = handicap(black, white)))
}
fun pair(): List<Game> { fun pair(): List<Game> {
// check that at this stage, we have an even number of pairables // check that at this stage, we have an even number of pairables
@@ -30,16 +35,16 @@ sealed class Solver(val history: List<Game>, val pairables: List<Pairable>, val
for (j in i + 1 until n) { for (j in i + 1 until n) {
val p = pairables[i] val p = pairables[i]
val q = pairables[j] val q = pairables[j]
builder.addEdge(p, q, weight(p, q)) weight(p, q).let { if (it != Double.NaN) builder.addEdge(p, q, it) }
builder.addEdge(q, p, weight(q, p)) weight(q, p).let { if (it != Double.NaN) builder.addEdge(q, p, it) }
} }
} }
val graph = builder.build() val graph = builder.build()
val matching = KolmogorovWeightedPerfectMatching(graph, ObjectiveSense.MINIMIZE) val matching = KolmogorovWeightedPerfectMatching(graph, ObjectiveSense.MINIMIZE)
val solution = matching.matching val solution = matching.matching
val result = solution.map { val result = solution.flatMap {
Game(Store.nextGameId, graph.getEdgeSource(it).id , graph.getEdgeTarget(it).id) games(black = graph.getEdgeSource(it) , white = graph.getEdgeTarget(it))
} }
return result return result
} }
@@ -94,49 +99,55 @@ sealed class Solver(val history: List<Game>, val pairables: List<Pairable>, val
} }
// score (number of wins) // score (number of wins)
val Pairable.score: Int get() = _score[id] ?: 0 val Pairable.score: Double get() = _score[id] ?: 0.0
private val _score: Map<Int, Int> by lazy { private val _score: Map<Int, Double> by lazy {
history.mapNotNull { game -> mutableMapOf<Int, Double>().apply {
history.forEach { game ->
when (game.result) { when (game.result) {
BLACK -> game.black BLACK -> put(game.black, getOrDefault(game.black, 0.0) + 1.0)
WHITE -> game.white WHITE -> put(game.white, getOrDefault(game.white, 0.0) + 1.0)
else -> null BOTHWIN -> {
put(game.black, getOrDefault(game.black, 0.0) + 0.5)
put(game.white, getOrDefault(game.white, 0.0) + 0.5)
}
else -> {}
}
}
} }
}.groupingBy { it }.eachCount()
} }
// sos // sos
val Pairable.sos: Int get() = _sos[id] ?: 0 val Pairable.sos: Double get() = _sos[id] ?: 0.0
private val _sos by lazy { private val _sos by lazy {
(history.map { game -> (history.map { game ->
Pair(game.black, _score[game.white] ?: 0) Pair(game.black, _score[game.white] ?: 0.0)
} + history.map { game -> } + history.map { game ->
Pair(game.white, _score[game.black] ?: 0) Pair(game.white, _score[game.black] ?: 0.0)
}).groupingBy { it.first }.fold(0) { acc, next -> }).groupingBy { it.first }.fold(0.0) { acc, next ->
acc + next.second acc + next.second
} }
} }
// sosos // sosos
val Pairable.sosos: Int get() = _sosos[id] ?: 0 val Pairable.sosos: Double get() = _sosos[id] ?: 0.0
private val _sosos by lazy { private val _sosos by lazy {
(history.map { game -> (history.map { game ->
Pair(game.black, _sos[game.white] ?: 0) Pair(game.black, _sos[game.white] ?: 0.0)
} + history.map { game -> } + history.map { game ->
Pair(game.white, _sos[game.black] ?: 0) Pair(game.white, _sos[game.black] ?: 0.0)
}).groupingBy { it.first }.fold(0) { acc, next -> }).groupingBy { it.first }.fold(0.0) { acc, next ->
acc + next.second acc + next.second
} }
} }
// sodos // sodos
val Pairable.sodos: Int get() = _sodos[id] ?: 0 val Pairable.sodos: Double get() = _sodos[id] ?: 0.0
private val _sodos by lazy { private val _sodos by lazy {
(history.map { game -> (history.map { game ->
Pair(game.black, if (game.result == BLACK) _score[game.white] ?: 0 else 0) Pair(game.black, if (game.result == BLACK) _score[game.white] ?: 0.0 else 0.0)
} + history.map { game -> } + history.map { game ->
Pair(game.white, if (game.result == WHITE) _score[game.black] ?: 0 else 0) Pair(game.white, if (game.result == WHITE) _score[game.black] ?: 0.0 else 0.0)
}).groupingBy { it.first }.fold(0) { acc, next -> }).groupingBy { it.first }.fold(0.0) { acc, next ->
acc + next.second acc + next.second
} }
} }

View File

@@ -11,24 +11,29 @@ class SwissSolver(history: List<Game>, pairables: List<Pairable>, weights: Pairi
override fun sort(p: Pairable, q: Pairable): Int = override fun sort(p: Pairable, q: Pairable): Int =
when (p.score) { when (p.score) {
q.score -> p.rating - q.rating q.score -> q.rating - p.rating
else -> p.score - q.score else -> ((q.score - p.score) * 1000).toInt()
} }
override fun weight(p: Pairable, q: Pairable) = when { override fun weight(black: Pairable, white: Pairable): Double {
p.played(q) -> weights.played var weight = 0.0
p.score != q.score -> { if (black.played(white)) weight += weights.played
if (black.score != white.score) {
val placeWeight = val placeWeight =
if (p.score > q.score) (p.placeInGroup.second + q.placeInGroup.first) * weights.place if (black.score > white.score) (black.placeInGroup.second + white.placeInGroup.first) * weights.place
else (q.placeInGroup.second + p.placeInGroup.first) * weights.place else (white.placeInGroup.second + black.placeInGroup.first) * weights.place
abs(p.score - q.score) * weights.score + placeWeight weight += abs(black.score - white.score) * weights.score + placeWeight
} } else {
else -> when (method) { weight += when (method) {
SPLIT_AND_FOLD -> SPLIT_AND_FOLD ->
if (p.placeInGroup.first > q.placeInGroup.first) abs(p.placeInGroup.first - (q.placeInGroup.second - q.placeInGroup.first)) * weights.place if (black.placeInGroup.first > white.placeInGroup.first) abs(black.placeInGroup.first - (white.placeInGroup.second - white.placeInGroup.first)) * weights.place
else abs(q.placeInGroup.first - (p.placeInGroup.second - p.placeInGroup.first)) * weights.place else abs(white.placeInGroup.first - (black.placeInGroup.second - black.placeInGroup.first)) * weights.place
SPLIT_AND_RANDOM -> rand.nextDouble() * p.placeInGroup.second * weights.place
SPLIT_AND_SLIP -> abs(abs(p.placeInGroup.first - q.placeInGroup.first) - p.placeInGroup.second) * weights.place SPLIT_AND_RANDOM -> rand.nextDouble() * black.placeInGroup.second * weights.place
SPLIT_AND_SLIP -> abs(abs(black.placeInGroup.first - white.placeInGroup.first) - black.placeInGroup.second) * weights.place
}
}
weight += (abs(black.colorBalance + 1) + abs(white.colorBalance - 1)) * weights.color
return weight
} }
} + (abs(p.colorBalance + 1) + abs(q.colorBalance - 1)) * weights.color
} }

View File

@@ -1,16 +1,15 @@
package org.jeudego.pairgoth.test package org.jeudego.pairgoth.test
import com.republicate.kson.Json import com.republicate.kson.Json
import org.junit.jupiter.api.MethodOrderer.Alphanumeric import org.junit.jupiter.api.MethodOrderer.MethodName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestMethodOrder import org.junit.jupiter.api.TestMethodOrder
import java.util.Objects
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@TestMethodOrder(Alphanumeric::class) @TestMethodOrder(MethodName::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BasicTests: TestBase() { class BasicTests: TestBase() {
@@ -51,8 +50,7 @@ class BasicTests: TestBase() {
), ),
"rounds" to 2, "rounds" to 2,
"pairing" to Json.Object( "pairing" to Json.Object(
"type" to "SWISS", "type" to "MACMAHON"
"method" to "SPLIT_AND_RANDOM"
) )
) )
@@ -60,7 +58,7 @@ class BasicTests: TestBase() {
"name" to "Burma", "name" to "Burma",
"firstname" to "Nestor", "firstname" to "Nestor",
"rating" to 1600, "rating" to 1600,
"rank" to -2, "rank" to -5,
"country" to "FR", "country" to "FR",
"club" to "13Ma" "club" to "13Ma"
) )
@@ -114,33 +112,59 @@ class BasicTests: TestBase() {
fun `005 pair`() { fun `005 pair`() {
val resp = TestAPI.post("/api/tour/1/part", anotherPlayer) as Json.Object val resp = TestAPI.post("/api/tour/1/part", anotherPlayer) as Json.Object
assertTrue(resp.getBoolean("success") == true, "expecting success") assertTrue(resp.getBoolean("success") == true, "expecting success")
val games = TestAPI.post("/api/tour/1/pair/1", Json.Array("all")) var games = TestAPI.post("/api/tour/1/pair/1", Json.Array("all"))
val possibleResults = setOf( val possibleResults = setOf(
"[{\"id\":1,\"w\":1,\"b\":2,\"r\":\"?\"}]", """[{"id":1,"w":1,"b":2,"h":0,"r":"?"}]""",
"[{\"id\":1,\"w\":2,\"b\":1,\"r\":\"?\"}]") """[{"id":1,"w":2,"b":1,"h":0,"r":"?"}]"""
)
assertTrue(possibleResults.contains(games.toString()), "pairing differs") assertTrue(possibleResults.contains(games.toString()), "pairing differs")
games = TestAPI.get("/api/tour/1/res/1") as Json.Array
assertTrue(possibleResults.contains(games.toString()), "results differs")
val empty = TestAPI.get("/api/tour/1/pair/1") as Json.Array
assertEquals("[]", empty.toString(), "no more pairables for round 1")
} }
@Test @Test
fun `006 team tournament`() { fun `006 result`() {
val resp = TestAPI.put("/api/tour/1/res/1", Json.parse("""{"id":1,"result":"b"}""")) as Json.Object
assertTrue(resp.getBoolean("success") == true, "expecting success")
val games = TestAPI.get("/api/tour/1/res/1")
val possibleResults = setOf(
"""[{"id":1,"w":1,"b":2,"h":0,"r":"b"}]""",
"""[{"id":1,"w":2,"b":1,"h":0,"r":"b"}]"""
)
assertTrue(possibleResults.contains(games.toString()), "results differ")
}
@Test
fun `007 team tournament, MacMahon`() {
var resp = TestAPI.post("/api/tour", aTeamTournament) as Json.Object var resp = TestAPI.post("/api/tour", aTeamTournament) as Json.Object
assertTrue(resp.getBoolean("success") == true, "expecting success") assertTrue(resp.getBoolean("success") == true, "expecting success")
assertEquals(2, resp.getInt("id"), "expecting id #2 for new tournament") assertEquals(2, resp.getInt("id"), "expecting id #2 for new tournament")
resp = TestAPI.post("api/tour/2/part", aPlayer) as Json.Object resp = TestAPI.post("/api/tour/2/part", aPlayer) as Json.Object
assertTrue(resp.getBoolean("success") == true, "expecting success") assertTrue(resp.getBoolean("success") == true, "expecting success")
assertEquals(3, resp.getInt("id"), "expecting id #3 for new player") assertEquals(3, resp.getInt("id"), "expecting id #3 for new player")
resp = TestAPI.post("api/tour/2/part", anotherPlayer) as Json.Object resp = TestAPI.post("/api/tour/2/part", anotherPlayer) as Json.Object
assertTrue(resp.getBoolean("success") == true, "expecting success") assertTrue(resp.getBoolean("success") == true, "expecting success")
assertEquals(4, resp.getInt("id"), "expecting id #{ for new player") assertEquals(4, resp.getInt("id"), "expecting id #{ for new player")
assertTrue(resp.getBoolean("success") == true, "expecting success") assertTrue(resp.getBoolean("success") == true, "expecting success")
var arr = TestAPI.get("/api/tour/2/pair/1") as Json.Array var arr = TestAPI.get("/api/tour/2/pair/1") as Json.Array
assertEquals("[]", arr.toString(), "expecting an empty array") assertEquals("[]", arr.toString(), "expecting an empty array")
resp = TestAPI.post("api/tour/2/team", Json.parse("""{ "name":"The Buffallos", "players":[3, 4] }""") as Json.Object) as Json.Object resp = TestAPI.post("/api/tour/2/team", Json.parse("""{ "name":"The Buffallos", "players":[3, 4] }""") as Json.Object) as Json.Object
assertTrue(resp.getBoolean("success") == true, "expecting success") assertTrue(resp.getBoolean("success") == true, "expecting success")
assertEquals(5, resp.getInt("id"), "expecting team id #5") assertEquals(5, resp.getInt("id"), "expecting team id #5")
resp = TestAPI.get("api/tour/2/team/5") as Json.Object resp = TestAPI.get("/api/tour/2/team/5") as Json.Object
assertEquals("""{"id":5,"name":"The Buffallos","players":[3,4]}""", resp.toString(), "expecting team description") assertEquals("""{"id":5,"name":"The Buffallos","players":[3,4]}""", resp.toString(), "expecting team description")
arr = TestAPI.get("/api/tour/2/pair/1") as Json.Array arr = TestAPI.get("/api/tour/2/pair/1") as Json.Array
assertEquals("[5]", arr.toString(), "expecting a singleton array") assertEquals("[5]", arr.toString(), "expecting a singleton array")
// nothing stops us in reusing players in different teams, at least for now...
resp = TestAPI.post("/api/tour/2/team", Json.parse("""{ "name":"The Billies", "players":[3, 4] }""") as Json.Object) as Json.Object
assertTrue(resp.getBoolean("success") == true, "expecting success")
assertEquals(6, resp.getInt("id"), "expecting team id #6")
arr = TestAPI.get("/api/tour/2/pair/1") as Json.Array
assertEquals("[5,6]", arr.toString(), "expecting two pairables")
arr = TestAPI.post("/api/tour/2/pair/1", Json.parse("""["all"]""")) as Json.Array
assertTrue(resp.getBoolean("success") == true, "expecting success")
val expected = """"["id":1,"w":5,"b":6,"h":3,"r":"?"]"""
} }
} }