Compare commits

...

9 Commits

Author SHA1 Message Date
320206118a Done 2025-10-08 20:14:20 +02:00
Claude Brisson
0cc34a1f84 Do not display pairing action buttons when pairing is empty 2025-07-25 05:19:19 +02:00
Claude Brisson
c3cb5826a3 Explain: fix a color inversion in heat map 2025-07-25 04:59:58 +02:00
Claude Brisson
84ab78c461 Bugfixing explain 2025-07-24 20:38:49 +02:00
Claude Brisson
d47d4fc8cc Beta version of explain page 2025-07-24 19:45:13 +02:00
Claude Brisson
3d06588889 Use a PairingListener class to collect or print weights, avoid computing twice the weights during tests 2025-07-24 15:05:51 +02:00
Claude Brisson
f704f3adb2 Code cleaning: fix previous commit, simplify HistoryHelper creation 2025-07-24 14:14:03 +02:00
Claude Brisson
ecec6556d1 Code cleaning: move history helper creation in tournament class, factorize main score function 2025-07-22 19:08:29 +02:00
Claude Brisson
17bb013feb Display individual standings below team standings 2025-06-11 11:03:58 +02:00
30 changed files with 974 additions and 387 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ target
*.iml
*~
pairgoth.db
ratings
pairgoth
pairgoth.tar.gz

View File

@@ -1,3 +1,5 @@
# This is largely a mirror of the original, adapted for the Slovenian Go Association.
# Pairgoth
Welcome to Pairgoth, your Go Pairing Engine!

View File

@@ -7,61 +7,23 @@ import org.jeudego.pairgoth.model.MacMahon
import org.jeudego.pairgoth.model.Pairable
import org.jeudego.pairgoth.model.PairingType
import org.jeudego.pairgoth.model.Player
import org.jeudego.pairgoth.model.TeamTournament
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.historyBefore
import org.jeudego.pairgoth.pairing.HistoryHelper
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.round
// TODO CB avoid code redundancy with solvers
fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = false): List<Json.Object> {
fun Pairable.mmBase(): Double {
if (pairing !is MacMahon) throw Error("invalid call: tournament is not Mac Mahon")
return min(max(rank, pairing.mmFloor), pairing.mmBar) + MacMahonSolver.mmsZero + mmsCorrection
}
fun roundScore(score: Double): Double {
val epsilon = 0.00001
// Note: this works for now because we only have .0 and .5 fractional parts
return if (pairing.pairingParams.main.roundDownScore) floor(score + epsilon)
else round(2 * score) / 2
}
if (frozen != null) {
return ArrayList(frozen!!.map { it -> it as Json.Object })
}
// CB TODO - factorize history helper creation between here and solver classes
val historyHelper = HistoryHelper(historyBefore(round + 1)) {
if (pairing.type == PairingType.SWISS) {
pairables.mapValues {
Pair(0.0, wins[it.key] ?: 0.0)
}
}
else {
pairables.mapValues {
it.value.let { pairable ->
val mmBase = pairable.mmBase()
val score = roundScore(mmBase +
(nbW(pairable) ?: 0.0) +
(1..round).map { round ->
if (playersPerRound.getOrNull(round - 1)?.contains(pairable.id) == true) 0.0 else 1.0
}.sum() * pairing.pairingParams.main.mmsValueAbsent)
Pair(
if (pairing.pairingParams.main.sosValueAbsentUseBase) mmBase
else roundScore(mmBase + round/2),
score
)
}
}
}
}
val history = historyHelper(round + 1)
val neededCriteria = ArrayList(pairing.placementParams.criteria)
if (!neededCriteria.contains(Criterion.NBW)) neededCriteria.add(Criterion.NBW)
if (!neededCriteria.contains(Criterion.RATING)) neededCriteria.add(Criterion.RATING)
@@ -72,24 +34,24 @@ fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = f
Criterion.CATEGORY -> StandingsHandler.nullMap
Criterion.RANK -> pairables.mapValues { it.value.rank }
Criterion.RATING -> pairables.mapValues { it.value.rating }
Criterion.NBW -> historyHelper.wins
Criterion.MMS -> historyHelper.mms
Criterion.SCOREX -> historyHelper.scoresX
Criterion.NBW -> history.wins
Criterion.MMS -> history.mms
Criterion.SCOREX -> history.scoresX
Criterion.STS -> StandingsHandler.nullMap
Criterion.CPS -> StandingsHandler.nullMap
Criterion.SOSW -> historyHelper.sos
Criterion.SOSWM1 -> historyHelper.sosm1
Criterion.SOSWM2 -> historyHelper.sosm2
Criterion.SODOSW -> historyHelper.sodos
Criterion.SOSOSW -> historyHelper.sosos
Criterion.CUSSW -> if (round == 0) StandingsHandler.nullMap else historyHelper.cumScore
Criterion.SOSM -> historyHelper.sos
Criterion.SOSMM1 -> historyHelper.sosm1
Criterion.SOSMM2 -> historyHelper.sosm2
Criterion.SODOSM -> historyHelper.sodos
Criterion.SOSOSM -> historyHelper.sosos
Criterion.CUSSM -> historyHelper.cumScore
Criterion.SOSW -> history.sos
Criterion.SOSWM1 -> history.sosm1
Criterion.SOSWM2 -> history.sosm2
Criterion.SODOSW -> history.sodos
Criterion.SOSOSW -> history.sosos
Criterion.CUSSW -> if (round == 0) StandingsHandler.nullMap else history.cumScore
Criterion.SOSM -> history.sos
Criterion.SOSMM1 -> history.sosm1
Criterion.SOSMM2 -> history.sosm2
Criterion.SODOSM -> history.sodos
Criterion.SOSOSM -> history.sosos
Criterion.CUSSM -> history.cumScore
Criterion.SOSTS -> StandingsHandler.nullMap
@@ -100,14 +62,14 @@ fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = f
Criterion.DC -> StandingsHandler.nullMap
}
}
val pairables = pairables.values.filter { includePreliminary || it.final }.map { it.toDetailedJson() }
pairables.forEach { player ->
val jsonPairables = pairables.values.filter { includePreliminary || it.final }.map { it.toDetailedJson() }
jsonPairables.forEach { player ->
for (crit in criteria) {
player[crit.first] = crit.second[player.getID()] ?: 0.0
}
player["results"] = Json.MutableArray(List(round) { "0=" })
}
val sortedPairables = pairables.sortedWith { left, right ->
val sortedPairables = jsonPairables.sortedWith { left, right ->
for (crit in criteria) {
val lval = left.getDouble(crit.first) ?: 0.0
val rval = right.getDouble(crit.first) ?: 0.0
@@ -129,15 +91,16 @@ fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = f
return sortedPairables
}
fun Tournament<*>.populateStandings(sortedPairables: List<Json.Object>, round: Int = rounds) {
val sortedMap = sortedPairables.associateBy {
fun Tournament<*>.populateStandings(sortedEntries: List<Json.Object>, round: Int = rounds, individualStandings: Boolean) {
val sortedMap = sortedEntries.associateBy {
it.getID()!!
}
// refresh name, firstname, club and level
val refMap = if (individualStandings) players else pairables
sortedMap.forEach { (id, pairable) ->
val mutable = pairable as Json.MutableObject
pairables[id]?.let {
refMap[id]?.let {
mutable["name"] = it.name
if (it is Player) {
mutable["firstname"] = it.firstname
@@ -150,7 +113,8 @@ fun Tournament<*>.populateStandings(sortedPairables: List<Json.Object>, round: I
// fill result
for (r in 1..round) {
games(r).values.forEach { game ->
val roundGames = if (individualStandings) individualGames(r) else games(r)
roundGames.values.forEach { game ->
val white = if (game.white != 0) sortedMap[game.white] else null
val black = if (game.black != 0) sortedMap[game.black] else null
val whiteNum = white?.getInt("num") ?: 0
@@ -186,3 +150,54 @@ fun Tournament<*>.populateStandings(sortedPairables: List<Json.Object>, round: I
}
}
}
fun TeamTournament.getSortedTeamMembers(round: Int, includePreliminary: Boolean = false): List<Json.Object> {
val teamGames = historyBefore(round + 1)
val individualHistory = teamGames.map { roundTeamGames ->
roundTeamGames.flatMap { game -> individualGames[game.id]?.toList() ?: listOf() }
}
val historyHelper = HistoryHelper(individualHistory).apply {
scoresFactory = { wins }
}
val neededCriteria = mutableListOf(Criterion.NBW, Criterion.RATING)
val criteria = neededCriteria.map { crit ->
crit.name to when (crit) {
Criterion.NBW -> historyHelper.wins
Criterion.RANK -> pairables.mapValues { it.value.rank }
Criterion.RATING -> pairables.mapValues { it.value.rating }
else -> null
}
}
val jsonPlayers = players.values.filter { includePreliminary || it.final }.map { it.toDetailedJson() }
jsonPlayers.forEach { player ->
for (crit in criteria) {
player[crit.first] = crit.second?.get(player.getID()) ?: 0.0
}
player["results"] = Json.MutableArray(List(round) { "0=" })
}
val sortedPlayers = jsonPlayers.sortedWith { left, right ->
for (crit in criteria) {
val lval = left.getDouble(crit.first) ?: 0.0
val rval = right.getDouble(crit.first) ?: 0.0
val cmp = lval.compareTo(rval)
if (cmp != 0) return@sortedWith -cmp
}
return@sortedWith 0
}.mapIndexed() { i, obj ->
obj.set("num", i+1)
}
var place = 1
sortedPlayers.groupBy { p ->
Triple(
criteria.getOrNull(0)?.first?.let { crit -> p.getDouble(crit) ?: 0.0 } ?: 0.0,
criteria.getOrNull(1)?.first?.let { crit -> p.getDouble(crit) ?: 0.0 } ?: 0.0,
criteria.getOrNull(2)?.first?.let { crit -> p.getDouble(crit) ?: 0.0 } ?: 0.0
)
}.forEach {
it.value.forEach { p -> p["place"] = place }
place += it.value.size
}
return sortedPlayers
}

View File

@@ -0,0 +1,68 @@
package org.jeudego.pairgoth.api
import com.republicate.kson.Json
import com.republicate.kson.toJsonArray
import com.republicate.kson.toJsonObject
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.pairing.solver.CollectingListener
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
object ExplainHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
val tournament = getTournament(request)
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
if (round > tournament.lastRound() + 1) badRequest("invalid round: previous round has not been played")
val paired = tournament.games(round).values.flatMap {
listOf(it.black, it.white)
}.filter {
it != 0
}.map {
tournament.pairables[it] ?: throw Error("Unknown pairable ID: $it")
}
val games = tournament.games(round).map { it.value }.toList()
// build the scores map by redoing the whole pairing
tournament.unpair(round)
val history = tournament.historyHelper(round)
val weightsCollector = CollectingListener()
tournament.pair(round, paired, false, weightsCollector)
val weights = weightsCollector.out
// Since weights are generally in two groups towards the min and the max,
// compute the max of the low group ("low") and the min of the high group ("high")
// to improve coloring.
// Total weights axis:
// ----[min]xxxx[low]----[middle]----[high]xxxx[max]---->
val min = weights.values.minOfOrNull { it.values.sum() } ?: 0.0
val max = weights.values.maxOfOrNull { it.values.sum() } ?: 0.0
val middle = (max - min) / 2.0
val low = weights.values.map { it.values.sum() }.filter { it < middle }.maxOrNull() ?: middle
val high = weights.values.map { it.values.sum() }.filter { it > middle }.minOrNull() ?: middle
val ret = Json.Object(
"paired" to paired.sortedByDescending { 1000 * (history.scores[it.id] ?: 0.0) + (history.sos[it.id] ?: 0.0) }.map {
it.toMutableJson().apply {
put("score", history.scores[it.id])
put("wins", history.wins[it.id])
put("sos", history.sos[it.id])
put("dudd", history.drawnUpDown[it.id])
}
}.toJsonArray(),
// "games" to games.map { it.toJson() }.toJsonArray(),
"games" to games.associateBy { "${it.white}-${it.black}" }.mapValues { it.value.toJson() }.toJsonObject(),
"weights" to weights.entries.map { (key, value) ->
Pair(
"${key.first}-${key.second}",
value.also {
it.put("total", it.values.sum())
}
)
}.toJsonObject(),
"min" to min,
"low" to low,
"high" to high,
"max" to max
)
return ret
}
}

View File

@@ -13,7 +13,10 @@ import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.toID
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.pairing.solver.LoggingListener
import org.jeudego.pairgoth.server.Event.*
import java.io.FileWriter
import java.io.PrintWriter
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@@ -67,7 +70,15 @@ object PairingHandler: PairgothApiHandler {
if (playing.contains(it.id)) badRequest("pairable #$id already plays round $round")
} ?: badRequest("invalid pairable id: #$id")
}
val games = tournament.pair(round, pairables)
// POST pair/$round accepts a few parameters to help tests
val legacy = request.getParameter("legacy")?.toBoolean() ?: false
val weightsLogger = request.getParameter("weights_output")?.let {
val append = request.getParameter("append")?.toBoolean() ?: false
LoggingListener(PrintWriter(FileWriter(it, append)))
}
val games = tournament.pair(round, pairables, legacy, weightsLogger)
val ret = games.map { it.toJson() }.toJsonArray()
tournament.dispatchEvent(GamesAdded, request, Json.Object("round" to round, "games" to ret))

View File

@@ -6,6 +6,7 @@ import org.jeudego.pairgoth.model.Criterion
import org.jeudego.pairgoth.model.Criterion.*
import org.jeudego.pairgoth.model.ID
import org.jeudego.pairgoth.model.PairingType
import org.jeudego.pairgoth.model.TeamTournament
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.model.adjustedTime
import org.jeudego.pairgoth.model.displayRank
@@ -27,10 +28,18 @@ object StandingsHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
val tournament = getTournament(request)
val round = getSubSelector(request)?.toIntOrNull() ?: tournament.rounds
val includePreliminary = request.getParameter("include_preliminary")?.let { it.toBoolean() } ?: false
val includePreliminary = request.getParameter("include_preliminary")?.toBoolean() ?: false
val sortedPairables = tournament.getSortedPairables(round, includePreliminary)
tournament.populateStandings(sortedPairables, round)
val individualStandings = tournament is TeamTournament &&
tournament.type.individual &&
request.getParameter("individual_standings")?.toBoolean() == true
val sortedEntries = if (individualStandings) {
tournament.getSortedTeamMembers(round)
} else {
tournament.getSortedPairables(round, includePreliminary)
}
tournament.populateStandings(sortedEntries, round, individualStandings)
val acceptHeader = request.getHeader("Accept") as String?
val accept = acceptHeader?.substringBefore(";")
@@ -44,7 +53,7 @@ object StandingsHandler: PairgothApiHandler {
PrintWriter(OutputStreamWriter(response.outputStream, encoding))
}
return when (accept) {
"application/json" -> sortedPairables.toJsonArray()
"application/json" -> sortedEntries.toJsonArray()
"application/egf" -> {
response.contentType = "text/plain;charset=${encoding}"
val neededCriteria = ArrayList(tournament.pairing.placementParams.criteria)
@@ -52,19 +61,19 @@ object StandingsHandler: PairgothApiHandler {
if (neededCriteria.first() == SCOREX) {
neededCriteria.add(1, MMS)
}
exportToEGFFormat(tournament, sortedPairables, neededCriteria, writer)
exportToEGFFormat(tournament, sortedEntries, neededCriteria, writer)
writer.flush()
return null
}
"application/ffg" -> {
response.contentType = "text/plain;charset=${encoding}"
exportToFFGFormat(tournament, sortedPairables, writer)
exportToFFGFormat(tournament, sortedEntries, writer)
writer.flush()
return null
}
"text/csv" -> {
response.contentType = "text/csv;charset=${encoding}"
exportToCSVFormat(tournament, sortedPairables, writer)
exportToCSVFormat(tournament, sortedEntries, writer)
writer.flush()
return null
}
@@ -123,7 +132,7 @@ ${
player.getString("num")!!.padStart(4, ' ')
} ${
"${
player.getString("name")?.toSnake(true)
player.getString("name")?.toSnake()
} ${
player.getString("firstname")?.toSnake() ?: ""

View File

@@ -4,8 +4,10 @@ import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.SPLIT_AND_SLIP
import org.jeudego.pairgoth.model.PairingType.*
import org.jeudego.pairgoth.pairing.solver.BaseSolver
import org.jeudego.pairgoth.pairing.HistoryHelper
import org.jeudego.pairgoth.pairing.solver.Solver
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
import org.jeudego.pairgoth.pairing.solver.PairingListener
import org.jeudego.pairgoth.pairing.solver.SwissSolver
import kotlin.math.min
@@ -131,28 +133,22 @@ sealed class Pairing(
val pairingParams: PairingParams,
val placementParams: PlacementParams) {
companion object {}
abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): BaseSolver
fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> {
return solver(tournament, round, pairables).pair()
internal abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): Solver
internal fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>, legacyMode: Boolean = false, listener: PairingListener? = null): List<Game> {
return solver(tournament, round, pairables)
.also { solver ->
solver.legacyMode = legacyMode
listener?.let {
solver.pairingListener = listener
}
}
.pair()
}
}
internal fun Tournament<*>.historyBefore(round: Int) =
(1 until min(round, lastRound() + 1)).map { games(it).values.toList() }
/*private fun Tournament<*>.historyBefore(round: Int) : List<List<Game>> {
println("Welcome to tournament.historyBefore !")
println("lastround and round = "+lastRound().toString()+" "+round.toString())
println((1 until round).map { it })
println((1 until round).map { games(it).values.toList() })
if (lastRound() == 1){
return emptyList()
}
else {
return (1 until round).map { games(it).values.toList() }
}
}*/
class Swiss(
pairingParams: PairingParams = PairingParams(
base = BaseCritParams(),
@@ -175,7 +171,7 @@ class Swiss(
): Pairing(SWISS, pairingParams, placementParams) {
companion object {}
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>) =
SwissSolver(round, tournament.rounds, tournament.historyBefore(round), pairables, tournament.pairables, pairingParams, placementParams, tournament.usedTables(round))
SwissSolver(round, tournament.rounds, HistoryHelper(tournament.historyBefore(round)), pairables, tournament.pairables, pairingParams, placementParams, tournament.usedTables(round))
}
class MacMahon(
@@ -203,14 +199,14 @@ class MacMahon(
): Pairing(MAC_MAHON, pairingParams, placementParams) {
companion object {}
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>) =
MacMahonSolver(round, tournament.rounds, tournament.historyBefore(round), pairables, tournament.pairables, pairingParams, placementParams, tournament.usedTables(round), mmFloor, mmBar)
MacMahonSolver(round, tournament.rounds, HistoryHelper(tournament.historyBefore(round)), pairables, tournament.pairables, pairingParams, placementParams, tournament.usedTables(round), mmFloor, mmBar)
}
class RoundRobin(
pairingParams: PairingParams = PairingParams(),
placementParams: PlacementParams = PlacementParams(Criterion.NBW, Criterion.RATING)
): Pairing(ROUND_ROBIN, pairingParams, placementParams) {
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): BaseSolver {
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): Solver {
TODO("not implemented")
}
}

View File

@@ -7,7 +7,9 @@ import com.republicate.kson.toJsonObject
//import kotlinx.datetime.LocalDate
import java.time.LocalDate
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.api.ApiHandler.Companion.logger
import org.jeudego.pairgoth.pairing.HistoryHelper
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
import org.jeudego.pairgoth.pairing.solver.PairingListener
import org.jeudego.pairgoth.store.nextGameId
import org.jeudego.pairgoth.store.nextPlayerId
import org.jeudego.pairgoth.store.nextTournamentId
@@ -15,8 +17,10 @@ import org.jeudego.pairgoth.util.MutableBiMultiMap
import org.jeudego.pairgoth.util.mutableBiMultiMapOf
import kotlin.math.max
import java.util.*
import java.util.regex.Pattern
import kotlin.collections.get
import kotlin.math.floor
import kotlin.math.min
import kotlin.math.round
import kotlin.math.roundToInt
sealed class Tournament <P: Pairable>(
@@ -61,7 +65,7 @@ sealed class Tournament <P: Pairable>(
var frozen: Json.Array? = null
// pairing
open fun pair(round: Int, pairables: List<Pairable>): List<Game> {
open fun pair(round: Int, pairables: List<Pairable>, legacyMode: Boolean = false, listener: PairingListener? = null): 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")
@@ -69,7 +73,7 @@ sealed class Tournament <P: Pairable>(
val evenPairables =
if (pairables.size % 2 == 0) pairables
else pairables.toMutableList().also { it.add(ByePlayer) }
return pairing.pair(this, round, evenPairables).also { newGames ->
return pairing.pair(this, round, evenPairables, legacyMode, listener).also { newGames ->
if (games.size < round) games.add(mutableMapOf())
games[round - 1].putAll( newGames.associateBy { it.id } )
}
@@ -96,14 +100,17 @@ sealed class Tournament <P: Pairable>(
fun lastRound() = max(1, games.size)
/**
* Recompute DUDD for a specific game
*/
fun recomputeDUDD(round: Int, gameID: ID) {
// Instantiate solver with game history
val solver = pairing.solver(this, round, pairables.values.toList())
val solver = pairing.solver(this, round, emptyList())
// Recomputes DUDD and hd
val game = games(round)[gameID]!!
val white = solver.pairables.find { p-> p.id == game.white }!!
val black = solver.pairables.find { p-> p.id == game.black }!!
val white = pairables[game.white]!!
val black = pairables[game.black]!!
game.drawnUpDown = solver.dudd(black, white)
game.handicap = solver.hd(white = white, black = black)
}
@@ -115,17 +122,16 @@ sealed class Tournament <P: Pairable>(
fun recomputeDUDD(round: Int) {
if (pairables.isEmpty() || games(1).isEmpty()) return;
// Instantiate solver with game history
val solver = pairing.solver(this, round, pairables.values.toList())
val solver = pairing.solver(this, round, emptyList())
for (game in games(round).values) {
if (game.black != 0 && game.white != 0) {
val white = solver.pairables.find { p-> p.id == game.white }!!
val black = solver.pairables.find { p-> p.id == game.black }!!
val white = pairables[game.white]!!
val black = pairables[game.black]!!
game.drawnUpDown = solver.dudd(black, white)
}
}
}
/**
* Recompute DUDD for all rounds
*/
@@ -208,6 +214,22 @@ sealed class Tournament <P: Pairable>(
}
return excluded
}
fun roundScore(score: Double): Double {
val epsilon = 0.00001
// Note: this works for now because we only have .0 and .5 fractional parts
return if (pairing.pairingParams.main.roundDownScore) floor(score + epsilon)
else round(2 * score) / 2
}
fun Pairable.mmBase(): Double {
if (pairing !is MacMahon) throw Error("invalid call: tournament is not Mac Mahon")
return min(max(rank, pairing.mmFloor), pairing.mmBar) + MacMahonSolver.mmsZero + mmsCorrection
}
fun historyHelper(round: Int): HistoryHelper {
return pairing.solver(this, round, emptyList()).history
}
}
// standard tournament of individuals
@@ -263,7 +285,7 @@ class TeamTournament(
override fun individualGames(round: Int): Map<ID, Game> {
val teamGames = games(round)
return if (type.individual) {
return teamGames.values.flatMap { game ->
teamGames.values.flatMap { game ->
if (game.white == 0 || game.black == 0 ) listOf()
else individualGames[game.id]?.toList() ?: listOf()
}.associateBy { it.id }
@@ -272,8 +294,8 @@ class TeamTournament(
}
}
override fun pair(round: Int, pairables: List<Pairable>) =
super.pair(round, pairables).also { games ->
override fun pair(round: Int, pairables: List<Pairable>, legacyMode: Boolean, listener: PairingListener?) =
super.pair(round, pairables, legacyMode, listener).also { games ->
if (type.individual) {
games.forEach { game ->
pairIndividualGames(round, game)

View File

@@ -5,26 +5,17 @@ import org.jeudego.pairgoth.model.*
abstract class BasePairingHelper(
val round: Int,
val totalRounds: Int,
history: List<List<Game>>, // History of all games played for each round
val history: HistoryHelper, // Digested history of all games played for each round
var pairables: List<Pairable>, // All pairables for this round, it may include the bye player
val pairablesMap: Map<ID, Pairable>, // Map of all known pairables for this tournament
val pairing: PairingParams,
val placement: PlacementParams,
) {
abstract val scores: Map<ID, Pair<Double, Double>>
abstract val scoresX: Map<ID, Double>
val historyHelper =
if (pairables.first().let { it is TeamTournament.Team && it.teamOfIndividuals }) TeamOfIndividualsHistoryHelper(
history
) { scores }
else HistoryHelper(history) { scores }
// Extend pairables with members from all rounds
// The main criterion that will be used to define the groups should be defined by subclasses
// SOS and variants will be computed based on this score
val Pairable.main: Double get() = scores[id]?.second ?: 0.0
val Pairable.main: Double get() = score ?: 0.0
abstract val mainLimits: Pair<Double, Double>
// pairables sorted using overloadable sort function
@@ -84,28 +75,28 @@ abstract class BasePairingHelper(
}
// already paired players map
protected fun Pairable.played(other: Pairable) = historyHelper.playedTogether(this, other)
protected fun Pairable.played(other: Pairable) = history.playedTogether(this, other)
// color balance (nw - nb)
protected val Pairable.colorBalance: Int get() = historyHelper.colorBalance(this) ?: 0
protected val Pairable.colorBalance: Int get() = history.colorBalance[id] ?: 0
protected val Pairable.group: Int get() = _groups[id]!!
protected val Pairable.drawnUpDown: Pair<Int, Int> get() = historyHelper.drawnUpDown(this) ?: Pair(0, 0)
protected val Pairable.drawnUpDown: Pair<Int, Int> get() = history.drawnUpDown[id] ?: Pair(0, 0)
protected val Pairable.nbBye: Int get() = historyHelper.nbPlayedWithBye(this) ?: 0
protected val Pairable.nbBye: Int get() = history.nbPlayedWithBye(this) ?: 0
// score (number of wins)
val Pairable.nbW: Double get() = historyHelper.nbW(this) ?: 0.0
val Pairable.sos: Double get() = historyHelper.sos[id] ?: 0.0
val Pairable.sosm1: Double get() = historyHelper.sosm1[id] ?: 0.0
val Pairable.sosm2: Double get() = historyHelper.sosm2[id] ?: 0.0
val Pairable.sosos: Double get() = historyHelper.sosos[id] ?: 0.0
val Pairable.sodos: Double get() = historyHelper.sodos[id] ?: 0.0
val Pairable.cums: Double get() = historyHelper.cumScore[id] ?: 0.0
val Pairable.score: Double get() = history.scores[id] ?: 0.0
val Pairable.scoreX: Double get() = history.scoresX[id] ?: 0.0
val Pairable.nbW: Double get() = history.wins[id] ?: 0.0
val Pairable.sos: Double get() = history.sos[id] ?: 0.0
val Pairable.sosm1: Double get() = history.sosm1[id] ?: 0.0
val Pairable.sosm2: Double get() = history.sosm2[id] ?: 0.0
val Pairable.sosos: Double get() = history.sosos[id] ?: 0.0
val Pairable.sodos: Double get() = history.sodos[id] ?: 0.0
val Pairable.cums: Double get() = history.cumScore[id] ?: 0.0
fun Pairable.missedRounds(): Int = (1 until round).map { round ->
if (historyHelper.playersPerRound.getOrNull(round - 1)
if (history.playersPerRound.getOrNull(round - 1)
?.contains(id) == true
) 0 else 1
}.sum()

View File

@@ -2,12 +2,22 @@ package org.jeudego.pairgoth.pairing
import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.model.Game.Result.*
import org.jeudego.pairgoth.model.TeamTournament.Team
import org.jeudego.pairgoth.pairing.solver.Solver
typealias ScoreMap = Map<ID, Double>
typealias ScoreMapFactory = () -> ScoreMap
open class HistoryHelper(
protected val history: List<List<Game>>,
// scoresGetter() returns Pair(sos value for missed rounds, score) where score is nbw for Swiss, mms for MM, ...
scoresGetter: HistoryHelper.()-> Map<ID, Pair<Double, Double>>) {
protected val history: List<List<Game>>
) {
lateinit var scoresFactory: ScoreMapFactory
lateinit var scoresXFactory: ScoreMapFactory
lateinit var missedRoundsSosFactory: ScoreMapFactory
val scores by lazy { scoresFactory() }
val scoresX by lazy { scoresXFactory() }
val missedRoundsSos by lazy { missedRoundsSosFactory() }
private val Game.blackScore get() = when (result) {
BLACK, BOTHWIN -> 1.0
@@ -19,23 +29,10 @@ open class HistoryHelper(
else -> 0.0
}
private val scores by lazy {
scoresGetter()
}
val scoresX by lazy {
scoresGetter().mapValues { entry ->
entry.value.first + (wins[entry.key] ?: 0.0)
}
}
// Generic helper functions
open fun playedTogether(p1: Pairable, p2: Pairable) = paired.contains(Pair(p1.id, p2.id))
open fun colorBalance(p: Pairable) = colorBalance[p.id]
open fun nbPlayedWithBye(p: Pairable) = nbPlayedWithBye[p.id]
open fun nbW(p: Pairable) = wins[p.id]
fun drawnUpDown(p: Pairable) = drawnUpDown[p.id]
protected val paired: Set<Pair<ID, ID>> by lazy {
(history.flatten().map { game ->
@@ -47,7 +44,7 @@ open class HistoryHelper(
// Returns the number of games played as white minus the number of games played as black
// Only count games without handicap
private val colorBalance: Map<ID, Int> by lazy {
val colorBalance: Map<ID, Int> by lazy {
history.flatten().filter { game ->
game.handicap == 0
}.filter { game ->
@@ -72,14 +69,14 @@ open class HistoryHelper(
}
}
// Set of all implied players for each round (warning: does comprise games with BIP)
// Set of all implied players for each round
val playersPerRound: List<Set<ID>> by lazy {
history.map {
it.fold(mutableSetOf<ID>()) { acc, next ->
if(next.white != 0) acc.add(next.white)
if (next.black != 0) acc.add(next.black)
acc
}
history.map { roundGames ->
roundGames.flatMap {
game -> listOf(game.white, game.black)
}.filter { id ->
id != ByePlayer.id
}.toSet()
}
}
@@ -100,67 +97,89 @@ open class HistoryHelper(
}
// define mms to be a synonym of scores
val mms by lazy { scores.mapValues { it -> it.value.second } }
val mms by lazy { scores }
val sos by lazy {
// SOS for played games against a real opponent or BIP
val historySos = (history.flatten().map { game ->
Pair(
game.black,
if (game.white == 0) scores[game.black]?.first ?: 0.0
else scores[game.white]?.second?.let { it - game.handicap } ?: 0.0
if (game.white == 0) missedRoundsSos[game.black] ?: 0.0
else scores[game.white]?.let { it - game.handicap } ?: 0.0
)
} + history.flatten().map { game ->
Pair(
game.white,
if (game.black == 0) scores[game.white]?.first ?: 0.0
else scores[game.black]?.second?.let { it + game.handicap } ?: 0.0
if (game.black == 0) missedRoundsSos[game.white] ?: 0.0
else scores[game.black]?.let { it + game.handicap } ?: 0.0
)
}).groupingBy {
it.first
}.fold(0.0) { acc, next ->
acc + next.second
}
scores.mapValues { (id, pair) ->
// plus SOS for missed rounds
missedRoundsSos.mapValues { (id, pseudoSos) ->
(historySos[id] ?: 0.0) + playersPerRound.sumOf {
if (it.contains(id)) 0.0 else pair.first
if (it.contains(id)) 0.0 else pseudoSos
}
}
}
// sos-1
val sosm1 by lazy {
// SOS for played games against a real opponent or BIP
(history.flatten().map { game ->
Pair(game.black, scores[game.white]?.second?.let { it - game.handicap } ?: 0.0)
Pair(
game.black,
if (game.white == 0) missedRoundsSos[game.black] ?: 0.0
else scores[game.white]?.let { it - game.handicap } ?: 0.0
)
} + history.flatten().map { game ->
Pair(game.white, scores[game.black]?.second?.let { it + game.handicap } ?: 0.0)
Pair(
game.white,
if (game.black == 0) missedRoundsSos[game.white] ?: 0.0
else scores[game.black]?.let { it + game.handicap } ?: 0.0
)
}).groupBy {
it.first
}.mapValues { (id, pairs) ->
val oppScores = pairs.map { it.second }.sortedDescending()
// minus greatest SOS
oppScores.sum() - (oppScores.firstOrNull() ?: 0.0) +
// plus SOS for missed rounds
playersPerRound.sumOf { players ->
if (players.contains(id)) 0.0
else scores[id]?.first ?: 0.0
else missedRoundsSos[id] ?: 0.0
}
}
}
// sos-2
val sosm2 by lazy {
// SOS for played games against a real opponent or BIP
(history.flatten().map { game ->
Pair(game.black, scores[game.white]?.second?.let { it - game.handicap } ?: 0.0)
Pair(
game.black,
if (game.white == 0) missedRoundsSos[game.black] ?: 0.0
else scores[game.white]?.let { it - game.handicap } ?: 0.0
)
} + history.flatten().map { game ->
Pair(game.white, scores[game.black]?.second?.let { it + game.handicap } ?: 0.0)
Pair(
game.white,
if (game.black == 0) missedRoundsSos[game.white] ?: 0.0
else scores[game.black]?.let { it + game.handicap } ?: 0.0
)
}).groupBy {
it.first
}.mapValues { (id, pairs) ->
val oppScores = pairs.map { it.second }.sorted()
val oppScores = pairs.map { it.second }.sortedDescending()
// minus two greatest SOS
oppScores.sum() - oppScores.getOrElse(0) { 0.0 } - oppScores.getOrElse(1) { 0.0 } +
// plus SOS for missed rounds
playersPerRound.sumOf { players ->
if (players.contains(id)) 0.0
else scores[id]?.first ?: 0.0
else missedRoundsSos[id] ?: 0.0
}
}
}
@@ -170,16 +189,17 @@ open class HistoryHelper(
(history.flatten().filter { game ->
game.white != 0 // Remove games against byePlayer
}.map { game ->
Pair(game.black, if (game.result == Game.Result.BLACK) scores[game.white]?.second?.let { it - game.handicap } ?: 0.0 else 0.0)
Pair(game.black, if (game.result == Game.Result.BLACK) scores[game.white]?.let { it - game.handicap } ?: 0.0 else 0.0)
} + history.flatten().filter { game ->
game.white != 0 // Remove games against byePlayer
}.map { game ->
Pair(game.white, if (game.result == Game.Result.WHITE) scores[game.black]?.second?.let { it + game.handicap } ?: 0.0 else 0.0)
Pair(game.white, if (game.result == Game.Result.WHITE) scores[game.black]?.let { it + game.handicap } ?: 0.0 else 0.0)
}).groupingBy { it.first }.fold(0.0) { acc, next ->
acc + next.second
}
}
// sosos
val sosos by lazy {
val currentRound = history.size
@@ -193,9 +213,9 @@ open class HistoryHelper(
acc + next.second
}
scores.mapValues { (id, pair) ->
missedRoundsSos.mapValues { (id, missedRoundSos) ->
(historySosos[id] ?: 0.0) + playersPerRound.sumOf {
if (it.contains(id)) 0.0 else pair.first * currentRound
if (it.contains(id)) 0.0 else missedRoundSos * currentRound
}
}
@@ -241,11 +261,4 @@ open class HistoryHelper(
else null
}
}
}
// CB TODO - a big problem with the current naive implementation is that the team score is -for now- the sum of team members individual scores
class TeamOfIndividualsHistoryHelper(history: List<List<Game>>, scoresGetter: () -> Map<ID, Pair<Double, Double>>):
HistoryHelper(history, { scoresGetter() }) {
}

View File

@@ -1,7 +1,6 @@
package org.jeudego.pairgoth.pairing
import org.jeudego.pairgoth.model.Pairable
import org.jeudego.pairgoth.pairing.solver.BaseSolver
fun detRandom(max: Double, p1: Pairable, p2: Pairable, symmetric: Boolean): Double {
var inverse = false

View File

@@ -1,6 +1,7 @@
package org.jeudego.pairgoth.pairing.solver
import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.pairing.HistoryHelper
import java.util.*
import kotlin.math.max
import kotlin.math.min
@@ -8,46 +9,41 @@ import kotlin.math.roundToInt
class MacMahonSolver(round: Int,
totalRounds: Int,
history: List<List<Game>>,
history: HistoryHelper,
pairables: List<Pairable>,
pairablesMap: Map<ID, Pairable>,
allPairablesMap: Map<ID, Pairable>,
pairingParams: PairingParams,
placementParams: PlacementParams,
usedTables: BitSet,
private val mmFloor: Int, private val mmBar: Int) :
BaseSolver(round, totalRounds, history, pairables, pairablesMap, pairingParams, placementParams, usedTables) {
Solver(round, totalRounds, history, pairables, allPairablesMap, pairingParams, placementParams, usedTables) {
override val scores: Map<ID, Pair<Double, Double>> by lazy {
require (mmBar > mmFloor) { "MMFloor is higher than MMBar" }
pairablesMap.mapValues {
it.value.let { pairable ->
val score = roundScore(pairable.mmBase +
override fun mainScoreMapFactory() =
allPairablesMap.mapValues { (id, pairable) ->
roundScore(pairable.mmBase +
pairable.nbW +
pairable.missedRounds() * pairingParams.main.mmsValueAbsent)
Pair(
if (pairingParams.main.sosValueAbsentUseBase) pairable.mmBase
else roundScore(pairable.mmBase + round/2),
score
)
}
}
pairable.missedRounds() * pairing.main.mmsValueAbsent)
}
override val scoresX: Map<ID, Double> by lazy {
require (mmBar > mmFloor) { "MMFloor is higher than MMBar" }
pairablesMap.mapValues {
it.value.let { pairable ->
override fun scoreXMapFactory() =
allPairablesMap.mapValues { (id, pairable) ->
roundScore(pairable.mmBase + pairable.nbW)
}
override fun missedRoundSosMapFactory() =
allPairablesMap.mapValues { (id, pairable) ->
if (pairing.main.sosValueAbsentUseBase) {
pairable.mmBase
} else {
roundScore(pairable.mmBase + round/2)
}
}
override fun computeWeightForBye(p: Pairable): Double{
return 2*scores[p.id]!!.second
return 2 * p.score
}
override fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double {
override fun SecondaryCritParams.playersMeetCriteria(p1: Pairable, p2: Pairable): Int {
// playersMeetCriteria = 0 : No player is above thresholds -> apply the full weight
// playersMeetCriteria = 1 : 1 player is above thresholds -> apply half the weight
// playersMeetCriteria = 2 : Both players are above thresholds -> do not apply weight
@@ -70,7 +66,7 @@ class MacMahonSolver(round: Int,
|| barThresholdActive && (p2.mmBase >= mmBar - Pairable.MIN_RANK)
|| p2.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++
return pairing.geo.apply(p1, p2, playersMeetCriteria)
return playersMeetCriteria
}
override fun HandicapParams.pseudoRank(pairable: Pairable): Int {
@@ -84,8 +80,7 @@ class MacMahonSolver(round: Int,
// mmBase: starting Mac-Mahon score of the pairable
val Pairable.mmBase: Double get() = min(max(rank, mmFloor), mmBar) + mmsZero + mmsCorrection
// mms: current Mac-Mahon score of the pairable
val Pairable.mms: Double get() = scores[id]?.second ?: 0.0
val Pairable.scoreX: Double get() = scoresX[id] ?: 0.0
val Pairable.mms: Double get() = score
// CB TODO - configurable criteria
val mainScoreMin = mmFloor + PLA_SMMS_SCORE_MIN - Pairable.MIN_RANK

View File

@@ -0,0 +1,75 @@
package org.jeudego.pairgoth.pairing.solver
import org.jeudego.pairgoth.model.ID
import org.jeudego.pairgoth.model.Pairable
import org.jeudego.pairgoth.model.Tournament
import java.io.PrintWriter
interface PairingListener {
fun start(round: Int) {}
fun startPair(white: Pairable, black: Pairable) {}
fun endPair(white: Pairable, black: Pairable) {}
fun addWeight(name: String, weight: Double)
fun end() {}
}
class LoggingListener(val out: PrintWriter) : PairingListener {
var currentOpenGothaWeight: Double = 0.0
override fun start(round: Int) {
out.println("Round $round")
out.println("Costs")
}
override fun startPair(white: Pairable, black: Pairable) {
currentOpenGothaWeight = 0.0
out.println("Player1Name=${white.fullName()}")
out.println("Player2Name=${black.fullName()}")
}
override fun addWeight(name: String, weight: Double) {
// Try hard to stay in sync with current reference files of OpenGotha conformance tests
val key = when (name) {
// TODO - Change to propagate to test reference files
"baseColorBalance" -> "baseBWBalance"
// Pairgoth-specific part of the color balance, not considered in conformance tests
"secColorBalance" -> return
else -> name
}
val value = when (name) {
// TODO - This cost is always zero in reference files, seems unused
"secHandi" -> 0.0
else -> weight
}
currentOpenGothaWeight += value
out.println("${key}Cost=$value")
}
override fun endPair(white: Pairable, black: Pairable) {
out.println("totalCost=$currentOpenGothaWeight")
}
override fun end() {
out.flush()
}
}
class CollectingListener() : PairingListener {
val out = mutableMapOf<Pair<ID, ID>, MutableMap<String, Double>>()
var white: Pairable? = null
var black: Pairable? = null
override fun startPair(white: Pairable, black: Pairable) {
this.white = white
this.black = black
}
override fun addWeight(name: String, weight: Double) {
val key = Pair(white!!.id, black!!.id)
val weights = out.computeIfAbsent(key) { mutableMapOf() }
weights[name] = weight
}
}

View File

@@ -4,6 +4,7 @@ import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.model.MainCritParams.DrawUpDown
import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.*
import org.jeudego.pairgoth.pairing.BasePairingHelper
import org.jeudego.pairgoth.pairing.HistoryHelper
import org.jeudego.pairgoth.pairing.detRandom
import org.jeudego.pairgoth.pairing.nonDetRandom
import org.jeudego.pairgoth.store.nextGameId
@@ -18,26 +19,48 @@ import java.text.DecimalFormat
import java.util.*
import kotlin.math.*
sealed class BaseSolver(
sealed class Solver(
round: Int,
totalRounds: Int,
history: List<List<Game>>, // History of all games played for each round
pairables: List<Pairable>, // All pairables for this round, it may include the bye player
pairablesMap: Map<ID, Pairable>, // Map of all known pairables in this tournament
history: HistoryHelper, // Digested history of all games played for each round
pairables: List<Pairable>, // Pairables to pair together
val allPairablesMap: Map<ID, Pairable>, // Map of all known pairables
pairing: PairingParams,
placement: PlacementParams,
val usedTables: BitSet
) : BasePairingHelper(round, totalRounds, history, pairables, pairablesMap, pairing, placement) {
) : BasePairingHelper(round, totalRounds, history, pairables, pairing, placement) {
companion object {
val rand = Random(/* seed from properties - TODO */)
// Used in tests
var weightsLogger: PrintWriter? = null
var legacy_mode = false
}
// For tests and explain feature
var legacyMode = false
var pairingListener: PairingListener? = null
init {
history.scoresFactory = this::mainScoreMapFactory
history.scoresXFactory = this::scoreXMapFactory
history.missedRoundsSosFactory = this::missedRoundSosMapFactory
}
/**
* Main score map factory (NBW for Swiss, MMS for MacMahon, ...).
*/
abstract fun mainScoreMapFactory(): Map<ID, Double>
/**
* ScoreX map factory (NBW for Swiss, MMSBase + MMS for MacMahon, ...).
*/
abstract fun scoreXMapFactory(): Map<ID, Double>
/**
* SOS for missed rounds factory (0 for Swiss, mmBase or mmBase+rounds/2 for MacMahon depending on pairing option sosValueAbsentUseBase)
*/
abstract fun missedRoundSosMapFactory(): Map<ID, Double>
open fun openGothaWeight(p1: Pairable, p2: Pairable) =
1.0 + // 1 is minimum value because 0 means "no matching allowed"
1.0 + // 1 is the minimum value because 0 means "no matching allowed"
pairing.base.apply(p1, p2) +
pairing.main.apply(p1, p2) +
pairing.secondary.apply(p1, p2)
@@ -49,20 +72,26 @@ sealed class BaseSolver(
else 0.0
}
open fun weight(p1: Pairable, p2: Pairable) =
open fun weight(p1: Pairable, p2: Pairable): Double {
pairingListener?.startPair(p1, p2)
return (
openGothaWeight(p1, p2) +
pairgothBlackWhite(p1, p2) +
pairgothBlackWhite(p1, p2).also { pairingListener?.addWeight("secColorBalance", it) } +
// pairing.base.applyByeWeight(p1, p2) +
pairing.handicap.color(p1, p2)
pairing.handicap.color(p1, p2).also { pairingListener?.addWeight("secHandi", it) }
).also {
pairingListener?.endPair(p1, p2)
}
}
open fun computeWeightForBye(p: Pairable): Double{
open fun computeWeightForBye(p: Pairable): Double {
// The weightForBye function depends on the system type (Mac-Mahon or Swiss), default value is 0.0
return 0.0
}
fun pair(): List<Game> {
// check that at this stage, we have an even number of pairables
// The BYE player should have been added beforehand to make a number of pairables even.
// The BYE player should have been added beforehand to make the number of pairables even.
if (pairables.size % 2 != 0) throw Error("expecting an even number of pairables")
val builder = GraphBuilder(SimpleDirectedWeightedGraph<Pairable, DefaultWeightedEdge>(DefaultWeightedEdge::class.java))
@@ -70,21 +99,18 @@ sealed class BaseSolver(
val logger = LoggerFactory.getLogger("debug")
val debug = false
weightsLogger?.apply {
this.println("Round $round")
this.println("Costs")
}
pairingListener?.start(round)
var chosenByePlayer: Pairable = ByePlayer
// Choose bye player and remove from pairables
if (ByePlayer in nameSortedPairables){
nameSortedPairables.remove(ByePlayer)
var minWeight = 1000.0*round + (Pairable.MAX_RANK - Pairable.MIN_RANK) + 1;
var minWeight = 1000.0 * round + (Pairable.MAX_RANK - Pairable.MIN_RANK) + 1;
var weightForBye : Double
var byePlayerIndex = 0
for (p in nameSortedPairables){
weightForBye = computeWeightForBye(p)
if (p.id in historyHelper.byePlayers) weightForBye += 1000
if (p.id in history.byePlayers) weightForBye += 1000
if (weightForBye <= minWeight){
minWeight = weightForBye
chosenByePlayer = p
@@ -102,21 +128,6 @@ sealed class BaseSolver(
val q = nameSortedPairables[j]
weight(p, q).let { if (it != Double.NaN) builder.addEdge(p, q, it/1e6) }
weight(q, p).let { if (it != Double.NaN) builder.addEdge(q, p, it/1e6) }
weightsLogger?.apply {
this.println("Player1Name=${p.fullName()}")
this.println("Player2Name=${q.fullName()}")
this.println("baseDuplicateGameCost=${dec.format(pairing.base.avoidDuplicatingGames(p, q))}")
this.println("baseRandomCost=${dec.format(pairing.base.applyRandom(p, q))}")
this.println("baseBWBalanceCost=${dec.format(pairing.base.applyColorBalance(p, q))}")
this.println("mainCategoryCost=${dec.format(pairing.main.avoidMixingCategory(p, q))}")
this.println("mainScoreDiffCost=${dec.format(pairing.main.minimizeScoreDifference(p, q))}")
this.println("mainDUDDCost=${dec.format(pairing.main.applyDUDD(p, q))}")
this.println("mainSeedCost=${dec.format(pairing.main.applySeeding(p, q))}")
this.println("secHandiCost=${dec.format(pairing.handicap.handicap(p, q))}")
this.println("secGeoCost=${dec.format(pairing.secondary.apply(p, q))}")
this.println("totalCost=${dec.format(openGothaWeight(p,q))}")
//File(WEIGHTS_FILE).appendText("ByeCost="+dec.format(pairing.base.applyByeWeight(p,q))+"\n")
}
}
}
val graph = builder.build()
@@ -131,6 +142,8 @@ sealed class BaseSolver(
// add game for ByePlayer
if (chosenByePlayer != ByePlayer) result += Game(id = nextGameId, table = 0, white = chosenByePlayer.id, black = ByePlayer.id, result = Game.Result.fromSymbol('w'))
pairingListener?.end()
if (debug) {
var sumOfWeights = 0.0
@@ -144,8 +157,8 @@ sealed class BaseSolver(
for (p in sortedPairables) {
logger.info(String.format("%-20s", p.name.substring(0, min(p.name.length, 18)))
+ " " + String.format("%-4s", p.id)
+ " " + String.format("%-4s", scores[p.id]?.first)
+ " " + String.format("%-4s", scores[p.id]?.second)
+ " " + String.format("%-4s", history.missedRoundsSos[p.id])
+ " " + String.format("%-4s", history.scores[p.id])
+ " " + String.format("%-4s", p.sos)
)
}
@@ -194,11 +207,11 @@ sealed class BaseSolver(
var score = 0.0
// Base Criterion 1 : Avoid Duplicating Game
// Did p1 and p2 already play ?
score += avoidDuplicatingGames(p1, p2)
score += avoidDuplicatingGames(p1, p2).also { pairingListener?.addWeight("baseDuplicateGame", it) }
// Base Criterion 2 : Random
score += applyRandom(p1, p2)
score += applyRandom(p1, p2).also { pairingListener?.addWeight("baseRandom", it) }
// Base Criterion 3 : Balance W and B
score += applyColorBalance(p1, p2)
score += applyColorBalance(p1, p2).also { pairingListener?.addWeight("baseColorBalance", it) }
return score
}
@@ -258,16 +271,16 @@ sealed class BaseSolver(
var score = 0.0
// Main criterion 1 avoid mixing category is moved to Swiss with category
score += avoidMixingCategory(p1, p2)
score += avoidMixingCategory(p1, p2).also { pairingListener?.addWeight("mainCategory", it) }
// Main criterion 2 minimize score difference
score += minimizeScoreDifference(p1, p2)
score += minimizeScoreDifference(p1, p2).also { pairingListener?.addWeight("mainScoreDiff", it) }
// Main criterion 3 If different groups, make a directed Draw-up/Draw-down
score += applyDUDD(p1, p2)
score += applyDUDD(p1, p2).also { pairingListener?.addWeight("mainDUDD", it) }
// Main criterion 4 seeding
score += applySeeding(p1, p2)
score += applySeeding(p1, p2).also { pairingListener?.addWeight("mainSeed", it) }
return score
}
@@ -389,7 +402,7 @@ sealed class BaseSolver(
val randRange = maxSeedingWeight * 0.2
// for old tests to pass
val rand =
if (legacy_mode && p1.fullName() > p2.fullName()) {
if (legacyMode && p1.fullName() > p2.fullName()) {
// for old tests to pass
detRandom(randRange, p2, p1, false)
} else {
@@ -405,8 +418,7 @@ sealed class BaseSolver(
return Math.round(score).toDouble()
}
open fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double {
open fun SecondaryCritParams.playersMeetCriteria(p1: Pairable, p2: Pairable): Int {
// playersMeetCriteria = 0 : No player is above thresholds -> apply secondary criteria
// playersMeetCriteria = 1 : 1 player is above thresholds -> apply half the weight
// playersMeetCriteria = 2 : Both players are above thresholds -> apply the full weight
@@ -419,7 +431,11 @@ sealed class BaseSolver(
if (2*p1.nbW >= nbw2Threshold) playersMeetCriteria++
if (2*p2.nbW >= nbw2Threshold) playersMeetCriteria++
return pairing.geo.apply(p1, p2, playersMeetCriteria)
return playersMeetCriteria
}
fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double {
return pairing.geo.apply(p1, p2, playersMeetCriteria(p1, p2))
}
fun GeographicalParams.apply(p1: Pairable, p2: Pairable, playersMeetCriteria: Int): Double {
@@ -427,11 +443,11 @@ sealed class BaseSolver(
val geoMaxCost = pairing.geo.avoidSameGeo
val countryFactor: Int = if (legacy_mode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold)
val countryFactor: Int = if (legacyMode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold)
preferMMSDiffRatherThanSameCountry
else
0
val clubFactor: Int = if (legacy_mode || biggestClubSize.toDouble() / pairables.size <= proportionMainClubThreshold)
val clubFactor: Int = if (legacyMode || biggestClubSize.toDouble() / pairables.size <= proportionMainClubThreshold)
preferMMSDiffRatherThanSameClub
else
0
@@ -489,7 +505,7 @@ sealed class BaseSolver(
2 -> geoMaxCost
1 -> 0.5 * (geoNominalCost + geoMaxCost)
else -> geoNominalCost
}
}.also { pairingListener?.addWeight("secGeo", it) }
}
// Handicap functions
@@ -537,7 +553,7 @@ sealed class BaseSolver(
} else if (p1.colorBalance < p2.colorBalance) {
score = 1.0
} else { // choose color from a det random
if (detRandom(1.0, p1, p2, false) === 0.0) {
if (detRandom(1.0, p1, p2, false) == 0.0) {
score = 1.0
} else {
score = -1.0

View File

@@ -1,31 +1,35 @@
package org.jeudego.pairgoth.pairing.solver
import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.pairing.HistoryHelper
import java.util.*
class SwissSolver(round: Int,
totalRounds: Int,
history: List<List<Game>>,
history: HistoryHelper,
pairables: List<Pairable>,
pairablesMap: Map<ID, Pairable>,
allPairablesMap: Map<ID, Pairable>,
pairingParams: PairingParams,
placementParams: PlacementParams,
usedTables: BitSet
):
BaseSolver(round, totalRounds, history, pairables, pairablesMap, pairingParams, placementParams, usedTables) {
Solver(round, totalRounds, history, pairables, allPairablesMap, pairingParams, placementParams, usedTables) {
// In a Swiss tournament the main criterion is the number of wins and already computed
override fun mainScoreMapFactory() =
allPairablesMap.mapValues { (id, pairable) ->
history.wins[id] ?: 0.0
}
override val scores by lazy {
pairablesMap.mapValues {
Pair(0.0, historyHelper.wins[it.value.id] ?: 0.0)
override fun scoreXMapFactory() = mainScoreMapFactory()
override fun missedRoundSosMapFactory() =
allPairablesMap.mapValues { (id, pairable) ->
0.0
}
}
override val scoresX: Map<ID, Double> get() = scores.mapValues { it.value.second }
override val mainLimits = Pair(0.0, round - 1.0)
override fun computeWeightForBye(p: Pairable): Double{
return p.rank + 40*p.main
return p.rank + 40 * p.main
}
}

View File

@@ -5,6 +5,7 @@ import com.github.benmanes.caffeine.cache.Caffeine
import com.republicate.kson.Json
import org.apache.commons.io.input.BOMInputStream
import org.jeudego.pairgoth.api.ApiHandler
import org.jeudego.pairgoth.api.ExplainHandler
import org.jeudego.pairgoth.api.PairingHandler
import org.jeudego.pairgoth.api.PlayerHandler
import org.jeudego.pairgoth.api.ResultsHandler
@@ -99,6 +100,7 @@ class ApiServlet: HttpServlet() {
if ("token" == selector) TokenHandler
else when (subEntity) {
null -> TournamentHandler
"explain" -> ExplainHandler
"part" -> PlayerHandler
"pair" -> PairingHandler
"res" -> ResultsHandler

View File

@@ -1,14 +1,12 @@
package org.jeudego.pairgoth.test
import com.republicate.kson.Json
import org.jeudego.pairgoth.pairing.solver.BaseSolver
import org.jeudego.pairgoth.model.Game
import org.jeudego.pairgoth.pairing.solver.Solver
import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights
import org.junit.jupiter.api.Test
import java.io.FileWriter
import java.io.PrintWriter
import java.nio.charset.StandardCharsets
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -22,24 +20,22 @@ class BOSP2024Test: TestBase() {
)!!.asObject()
val resp = TestAPI.post("/api/tour", tournament).asObject()
val tourId = resp.getInt("id")
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("bosp2024-weights.txt")))
BaseSolver.legacy_mode = true
TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray()
val outputFile = getOutputFile("bosp2024-weights.txt")
TestAPI.post("/api/tour/$tourId/pair/3?legacy=true&weights_output=$outputFile", Json.Array("all")).asArray()
// compare weights
assertTrue(compare_weights(getOutputFile("bosp2024-weights.txt"), getTestFile("opengotha/bosp2024/bosp2024_weights_R3.txt")), "Not matching opengotha weights for BOSP test")
assertTrue(compare_weights(outputFile, getTestFile("opengotha/bosp2024/bosp2024_weights_R3.txt")), "Not matching opengotha weights for BOSP test")
TestAPI.delete("/api/tour/$tourId/pair/3", Json.Array("all"))
BaseSolver.legacy_mode = false
val games = TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray()
// Aksut Husrev is ID 18
val solved = games.map { it as Json.Object }.filter { game ->
val solved = games.map { it as Json.Object }.firstOrNull { game ->
// build the two-elements set of players ids
val players = game.entries.filter { (k, v) -> k == "b" || k == "w" }.map { (k, v) -> (v as Number).toInt() }.toSet()
val players =
game.entries.filter { (k, v) -> k == "b" || k == "w" }.map { (k, v) -> (v as Number).toInt() }.toSet()
// keep game with Aksut Husrev
players.contains(18)
}.firstOrNull()
}
assertNotNull(solved)

View File

@@ -1,11 +1,8 @@
package org.jeudego.pairgoth.test
import com.republicate.kson.Json
import org.jeudego.pairgoth.pairing.solver.BaseSolver
import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights
import org.junit.jupiter.api.Test
import java.io.FileWriter
import java.io.PrintWriter
import java.nio.charset.StandardCharsets
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -19,8 +16,8 @@ class MalavasiTest: TestBase() {
)!!.asObject()
val resp = TestAPI.post("/api/tour", tournament).asObject()
val tourId = resp.getInt("id")
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("malavasi-weights.txt")))
val games = TestAPI.post("/api/tour/$tourId/pair/2", Json.Array("all")).asArray()
val outputFile = getOutputFile("malavasi-weights.txt")
val games = TestAPI.post("/api/tour/$tourId/pair/2?weights_output=$outputFile", Json.Array("all")).asArray()
// Oceane is ID 548, Valentine 549
val buggy = games.map { it as Json.Object }.filter { game ->
// build the two-elements set of players ids
@@ -33,6 +30,6 @@ class MalavasiTest: TestBase() {
assertEquals(2, buggy.size)
// compare weights
assertTrue(compare_weights(getOutputFile("malavasi-weights.txt"), getTestFile("opengotha/malavasi/malavasi_weights_R2.txt")), "Not matching opengotha weights for Malavasi test")
assertTrue(compare_weights(outputFile, getTestFile("opengotha/malavasi/malavasi_weights_R2.txt")), "Not matching opengotha weights for Malavasi test")
}
}

View File

@@ -2,14 +2,12 @@ package org.jeudego.pairgoth.test
import com.republicate.kson.Json
import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.pairing.solver.BaseSolver
import org.jeudego.pairgoth.pairing.solver.Solver
import org.jeudego.pairgoth.store.MemoryStore
import org.jeudego.pairgoth.store.lastPlayerId
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
import kotlin.math.abs
@@ -59,7 +57,6 @@ class PairingTests: TestBase() {
}
fun compare_weights(file1: File, file2: File, skipSeeding: Boolean = false):Boolean {
BaseSolver.weightsLogger!!.flush()
// Maps to store name pairs and costs
val map1 = create_weights_map(file1)
val map2 = create_weights_map(file2)
@@ -165,6 +162,7 @@ class PairingTests: TestBase() {
}
fun test_from_XML(name: String, forcePairing:List<Int>) {
// Let pairgoth use the legacy asymmetric detRandom()
test_from_XML_internal(name, forcePairing, true)
// Non-legacy tests inhibited for now: pairings differ for Toulouse and SimpleMM
// test_from_XML_internal(name, forcePairing, false)
@@ -172,11 +170,10 @@ class PairingTests: TestBase() {
fun test_from_XML_internal(name: String, forcePairing:List<Int>, legacy: Boolean) {
// Let pairgoth use the legacy asymmetric detRandom()
BaseSolver.legacy_mode = legacy
// read tournament with pairing
val file = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
val tourFile = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $tourFile")
val resource = tourFile.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
@@ -203,15 +200,15 @@ class PairingTests: TestBase() {
for (round in 1..tournament.getInt("rounds")!!) {
val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round-1], players)
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
val outputFile = getOutputFile("weights.txt")
// Call Pairgoth pairing solver to generate games
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
games = TestAPI.post("/api/tour/$id/pair/$round?legacy=$legacy&weights_output=$outputFile", Json.Array("all")).asArray()
logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG))
logger.info("games for round $round: {}", games.toString())
// Compare weights with OpenGotha if legacy mode
if (legacy) {
assertTrue(compare_weights(getOutputFile("weights.txt"), getTestFile("opengotha/$name/$name"+"_weights_R$round.txt")), "Not matching opengotha weights for round $round")
assertTrue(compare_weights(outputFile, getTestFile("opengotha/$name/$name"+"_weights_R$round.txt")), "Not matching opengotha weights for round $round")
}
if (round in forcePairing) {
@@ -223,7 +220,7 @@ class PairingTests: TestBase() {
val gameOG = pairingsOG[round - 1].getJson(i)!!.asObject()// ["r"] as String?
val whiteId = gameOG["w"] as Long?
val blackId = gameOG["b"] as Long?
TestAPI.put("/api/tour/$id/pair/$round", Json.parse("""{"id":$gameID,"w":$whiteId,"b":$blackId}""")).asObject()
TestAPI.put("/api/tour/$id/pair/$round?legacy=$legacy&weights_output=$outputFile&append=true", Json.parse("""{"id":$gameID,"w":$whiteId,"b":$blackId}""")).asObject()
}
games = TestAPI.get("/api/tour/$id/res/$round").asArray()
}
@@ -273,11 +270,10 @@ class PairingTests: TestBase() {
@Test
fun `SwissTest simpleSwiss`() {
BaseSolver.legacy_mode = true
// read tournament with pairing
var file = getTestFile("opengotha/pairings/simpleswiss.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
var tourFile = getTestFile("opengotha/pairings/simpleswiss.xml")
logger.info("read from file $tourFile")
val resource = tourFile.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
@@ -315,10 +311,10 @@ class PairingTests: TestBase() {
var firstGameID: Int
for (round in 1..7) {
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
val outputFile = getOutputFile("weights.txt")
games = TestAPI.post("/api/tour/$id/pair/$round?legacy=true&weights_output=$outputFile", Json.Array("all")).asArray()
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
assertTrue(compare_weights(getOutputFile("weights.txt"), getTestFile("opengotha/simpleswiss/simpleswiss_weights_R$round.txt")), "Not matching opengotha weights for round $round")
assertTrue(compare_weights(outputFile, getTestFile("opengotha/simpleswiss/simpleswiss_weights_R$round.txt")), "Not matching opengotha weights for round $round")
assertTrue(compare_games(games, Json.parse(pairingsOG[round - 1])!!.asArray()),"pairings for round $round differ")
logger.info("Pairings for round $round match OpenGotha")
@@ -354,12 +350,12 @@ class PairingTests: TestBase() {
@Test
fun `SwissTest KPMCSplitbug`() {
// Let pairgoth use the legacy asymmetric detRandom()
BaseSolver.legacy_mode = true
val legacy = true
// read tournament with pairing
val name = "20240921-KPMC-Splitbug"
val file = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
val tourFile = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $tourFile")
val resource = tourFile.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
@@ -387,13 +383,13 @@ class PairingTests: TestBase() {
var games: Json.Array
var firstGameID: Int
val outputFile = getOutputFile("weights.txt")
for (round in minRound..maxRound) {
val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round - minRound], players)
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
// Call Pairgoth pairing solver to generate games
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
games = TestAPI.post("/api/tour/$id/pair/$round?legacy=$legacy&weights_ouput=$outputFile&append=${round > 1}", Json.Array("all")).asArray()
logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG))
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
@@ -401,7 +397,7 @@ class PairingTests: TestBase() {
// Compare weights with OpenGotha
assertTrue(
compare_weights(
getOutputFile("weights.txt"),
outputFile,
getTestFile("opengotha/$name/$name" + "_weights_R$round.txt")
), "Not matching opengotha weights for round $round"
)

View File

@@ -7,6 +7,8 @@ import org.jeudego.pairgoth.server.SSEServlet
import org.jeudego.pairgoth.server.WebappManager
import org.mockito.kotlin.*
import java.io.*
import java.net.URL
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.*
import javax.servlet.ReadListener
@@ -21,20 +23,45 @@ object TestAPI {
fun Any?.toUnit() = Unit
fun parseURL(url: String): Pair<String, Map<String, String>> {
val qm = url.indexOf('?')
if (qm == -1) {
return url to emptyMap()
}
val uri = url.substring(0, qm)
val params = url.substring(qm + 1)
.split('&')
.map { it.split('=') }
.mapNotNull {
when (it.size) {
1 -> it[0].decodeUTF8() to ""
2 -> it[0].decodeUTF8() to it[1].decodeUTF8()
else -> null
}
}
.toMap()
return uri to params
}
private fun String.decodeUTF8() = URLDecoder.decode(this, "UTF-8") // decode page=%22ABC%22 to page="ABC"
private val apiServlet = ApiServlet()
private val sseServlet = SSEServlet()
private fun <T> testRequest(reqMethod: String, uri: String, accept: String = "application/json", payload: T? = null): String {
private fun <T> testRequest(reqMethod: String, url: String, accept: String = "application/json", payload: T? = null): String {
WebappManager.properties["auth"] = "none"
WebappManager.properties["store"] = "memory"
WebappManager.properties["webapp.env"] = "test"
val (uri, parameters) = parseURL(url)
// mock request
val myHeaderNames = if (reqMethod == "GET") emptyList() else listOf("Content-Type")
val selector = argumentCaptor<String>()
val subSelector = argumentCaptor<String>()
val reqPayload = argumentCaptor<String>()
val parameter = argumentCaptor<String>()
val myInputStream = payload?.let { DelegatingServletInputStream(payload.toString().byteInputStream(StandardCharsets.UTF_8)) }
val myReader = payload?.let { BufferedReader(StringReader(payload.toString())) }
val req = mock<HttpServletRequest> {
@@ -59,6 +86,7 @@ object TestAPI {
}
on { headerNames } doReturn Collections.enumeration(myHeaderNames)
on { getHeader(eq("Accept")) } doReturn accept
on { getParameter(parameter.capture()) } doAnswer { parameters[parameter.lastValue] }
}
// mock response
@@ -77,7 +105,7 @@ object TestAPI {
"DELETE" -> apiServlet.doDelete(req, resp)
}
return buffer.toString() ?: throw Error("no response payload")
return buffer.toString()
}
fun get(uri: String): Json = Json.parse(testRequest<Void>("GET", uri)) ?: throw Error("no payload")

23
build.sh Executable file
View File

@@ -0,0 +1,23 @@
mkdir pairgoth
cp application/target/pairgoth-engine.jar pairgoth/
echo "#!/bin/bash" > pairgoth/run.sh
echo "./jdk-11.0.28+6-jre/bin/java -jar pairgoth-engine.jar" >> pairgoth/run.sh
echo "#!/bin/bash" > pairgoth/get_java.sh
echo "wget https://github.com/adoptium/temurin11-binaries/releases/download/jdk-11.0.28%2B6/OpenJDK11U-jre_x64_linux_hotspot_11.0.28_6.tar.gz" >> pairgoth/get_java.sh
echo "tar -xzf OpenJDK11U-jre_x64_linux_hotspot_11.0.28_6.tar.gz" >> pairgoth/get_java.sh
chmod +x pairgoth/run.sh
chmod +x pairgoth/get_java.sh
echo "" > pairgoth/pairgoth.properties
echo "webapp.env = prod" >> pairgoth/pairgoth.properties
echo "webapp.url = http://localhost:8080" >> pairgoth/pairgoth.properties
echo "auth = none" >> pairgoth/pairgoth.properties
echo "logger.level = info" >> pairgoth/pairgoth.properties
echo "rating.ffg.enable = false" >> pairgoth/pairgoth.properties
echo "webapp.protocol = http" >> pairgoth/pairgoth.properties
tar -czvf pairgoth.tar.gz pairgoth/

View File

@@ -0,0 +1,143 @@
@layer pairgoth {
/* explain section */
#pairing-table-wrapper {
padding: 8em 2em 1em 2em;
}
#pairing-table {
border-collapse: collapse;
width: max-content;
thead {
//border-collapse: collapse;
tr {
&:first-child {
min-height: 8rem;
th:first-child {
background: linear-gradient(to right top, #ffffff 0%,#ffffff 49.9%,#000000 50%,#000000 51%,#ffffff 51.1%,#ffffff 100%);
position: relative;
min-width: 8rem;
label.top-right {
position: absolute;
top: 0;
right: 0;
}
label.bottom-left {
position: absolute;
bottom: 0;
left: 0;
}
}
th:not(:first-child) {
z-index: 5;
width: 55px;
height: 140px;
white-space: nowrap;
padding-bottom: 10px;
position: relative;
> div {
transform: translate(35px, 51px) rotate(315deg);
width: 30px;
&:hover {
cursor: pointer;
> span {
background-color: rgba(0, 0, 255, 0.2) !important;
}
}
> span {
border-bottom: 1px solid gray;
padding: 5px 10px;
}
}
> pre {
display: none;
font-size: smaller;
line-height: 1em;
text-align: left;
position: absolute;
pointer-events: none;
top: 100%;
left: 100%;
background-color: rgba(255, 255, 255, 0.7);
}
&:hover {
cursor: pointer;
> pre {
display: block;
z-index: 10;
}
}
}
}
}
}
tbody {
tr {
th {
text-align: left;
padding-right: 1em;
position: relative;
> pre {
display: none;
font-size: smaller;
line-height: 1em;
text-align: left;
position: absolute;
pointer-events: none;
top: 100%;
left: 100%;
background-color: rgba(255, 255, 255, 0.7);
}
&:hover {
cursor: pointer;
background-color: rgba(0, 0, 255, 0.2) !important;
pre {
display: block;
z-index: 10;
}
}
}
td {
height: 55px;
width: 55px;
border: solid 1px gray;
position: relative;
&.game::after {
position: absolute;
content: "X";
color: blue;
text-align: center;
top: 50%;
transform: translateY(-50%);
bottom: 0;
left: 0;
right: 0;
}
.weights {
display: none;
font-size: smaller;
line-height: 1em;
text-align: left;
position: absolute;
pointer-events: none;
top: 100%;
left: 100%;
background-color: rgba(255, 255, 255, 0.7);
}
&:hover {
background-color: rgba(0, 0, 255, 0.7) !important;
cursor: pointer;
.weights {
display: block;
z-index: 10;
}
}
}
}
}
}
#captures {
padding: 1em;
}
}

View File

@@ -56,6 +56,10 @@
font-style: italic;
}
.strong {
font-weight: bold;
}
/* header, center, footer */
#header {
@@ -566,7 +570,7 @@
}
/* TODO - plenty of those elements could just use the .noprint class */
#header, #logo, #lang, .steps, #filter-box, #reglist-mode, #footer, #unpairables, #pairing-buttons, button, #standings-params, #logout, .pairing-stats, .result-sheets, .toggle, #overview, .tables-exclusion, .button, .noprint {
#header, #logo, #lang, .steps, #filter-box, #reglist-mode, #footer, #unpairables, #pairing-buttons, button, #standings-params, #logout, .pairing-stats, .pairing-post-actions, .toggle, #overview, .tables-exclusion, .button, .noprint {
display: none !important;
}

View File

@@ -377,6 +377,9 @@
width: 6em;
}
}
&:empty + .pairing-post-actions {
display: none;
}
}
#print-pairables {
width: 100%;
@@ -418,8 +421,10 @@
padding: 0.2em 0.8em;
}
.result-sheets {
.pairing-post-actions {
margin-top: 0.2em;
display: flex;
justify-content: space-around;
}
.bottom-pairing-actions {

View File

@@ -184,7 +184,7 @@ unpairable, non disponibles,
supports the implémente le système dappariement
white blanc
White Blanc
white vs. black blanc vs. Noir
white vs. black Blanc vs. Noir
confirmed. confirmé(s).
Note that login to this instance is reserved to French federation actors plus several external people at our discretion. Send us La connexion à cette instance est réservée aux acteurs de la FFG et à quelques personnes extérieures, à notre discrétion. Envoyez-nous
yyyymmdd-city aaaammjj-ville

View File

@@ -0,0 +1,115 @@
#macro(rank $rank)#if( $rank<0 )#set( $k = -$rank )${k}k#else#set( $d=$rank+1 )${d}d#end#end
#if (!$tour)
<div class="section">
<h2 class="error">Invalid tournament id</h2>
</div>
#stop
#end
#set($round = $math.toInteger($!params.round))
#if(!$round)
#set($round = 1)
#else
#set($round = $math.min($math.max($round, 1), $tour.rounds))
#end
#if($tour.type == 'INDIVIDUAL' || $tour.type.startsWith('TEAM'))
#set($parts = $api.get("tour/${params.id}/part"))
#else
#set($parts = $api.get("tour/${params.id}/team"))
#end
#set($pmap = $utils.toMap($parts))
#set($roundPairing = $api.get("tour/${params.id}/pair/$round"))
#if($roundPairing.error)
<script type="text/javascript">
onLoad(() => {
showError("$roundPairing.error")
});
</script>
#stop
#end
#set($explain = $api.get("tour/${params.id}/explain/$round"))
<div id="pairing-table-wrapper">
<table id="pairing-table">
<thead>
<tr>
<th>
<label class="top-right">white</label>
<label class="bottom-left">black</label>
</th>
#foreach($white in $explain.paired)
<th data-white="$white.id">
<div>
<span>
$white.name $white.firstname
</span>
</div>
<pre>$white.toPrettyString()</pre>
</th>
#end
</tr>
</thead>
<tbody>
#foreach($black in $explain.paired)
<tr>
<th data-black="$black.id">
<div>
<span>
$black.name $black.firstname
</span>
</div>
<pre>$black.toPrettyString()</pre>
</th>
#foreach($white in $explain.paired)
#if($white.id != $black.id)
#set($key = "$white.id-$black.id")
#set($weights = $explain.weights[$key])
#if($weights)
#set($toMax = $explain.max - $weights.total)
#set($toMin = $weights.total - $explain.min)
#if ($toMax > $toMin)
## total is close to min
#set($percent = ($weights.total - $explain.min) / ($explain.low - $explain.min) * 40)
#else
## total is close to max
#set($percent = 60 + 40 * (1 - ($explain.max - $weights.total) / ($explain.max - $explain.high)) )
#end
#end
#set($game = $explain.games[$key])
<td data-wb="$white.id-$black.id" #if($game)class="game"#end #if($weights)style="background-color: color-mix(in srgb, rgb(0 255 0) ${percent}%, rgb(255 0 0));"#end>
<div class="weights">
<pre>#if($weights)$weights.toPrettyString()#{else}Bye Player#end</pre>
</div>
</td>
#else
<td></td>
#end
#end
</tr>
#end
</tbody>
</table>
</div>
<div id="captures"></div>
<script type="text/javascript">
// #[[
onLoad(() => {
$('#header').hide();
$('td').on('click', e => {
const td = e.target.closest('td');
const ids = td.data('wb')?.split(/-/);
if (ids) {
const white = $(`th[data-white="${ids[0]}"] div span`)?.text();
const black = $(`th[data-white="${ids[1]}"] div span`)?.text();
const weights = td.find('.weights pre').text();
const captures = $('#captures')[0];
captures.insertAdjacentHTML('beforeend', `<div>${white} vs ${black}<pre>${weights}</pre></div>`);
}
})
$('th[data-white], th[data-black]').on('click', e => {
const th = e.target.closest('th');
const name = th.find('span').text();
const info = th.find('pre').text();
captures.insertAdjacentHTML('beforeend', `<div>${name}<pre>${info}</pre></div>`);
});
});
// ]]#
</script>

View File

@@ -3,6 +3,7 @@
<div class="section">
<h2 class="error">Invalid tournament id</h2>
</div>
#stop
#end
#set($round = $math.toInteger($!params.round))
#if(!$round)
@@ -27,60 +28,40 @@
#stop
#end
#set($games = $utils.removeBye($roundPairing.games))
#set($pages = ($games.size() + 3) / 4)
#set($items = $pages * 4)
#foreach($i in [1..$items])
#set($j = ($i - 1) / 4 + (($i - 1) % 4) * $pages)
#if($j < $games.size())
#set($game = $games[$j])
<table>
<tr>
<th>Table</th>
<th>Black</th>
<th>Rank</th>
<th>White</th>
<th>Rank</th>
<th>Handicap</th>
<th>Komi</th>
</tr>
#set($numOfPlay = $games.size() - 1)
#foreach($i in [0..$numOfPlay])
#set($game = $games[$i])
#set($white = $pmap[$game.w])
#set($black = $pmap[$game.b])
#else
#set($game = { 't': 'xxx', 'h': 'xxx' })
#set($white = { 'name': 'xxx', 'firstname': 'xxx', 'rank': -99, 'country': 'XX', 'club': 'xxx' })
#set($black = { 'name': 'xxx', 'firstname': 'xxx', 'rank': -99, 'country': 'XX', 'club': 'xxx' })
#end
#if($foreach.index % 4 == 0)
<div class="page">
#end
<div class="page-item">
<div class="title">$tour.name</div>
<div class="subtitle"></div>
<div class="details">
<div>Table $game.t</div>
#set($komi = $tour.komi)
#if($game.h) #set($komi = $komi - $math.floor($komi)) #end
<div>Handicap $game.h &nbsp;&dash;&nbsp; Komi $komi</div>
<div>Round $round</div>
</div>
<div class="instructions">
Surround winner's name or ½-½
</div>
<div class="players">
<div class="white player">
<div class="color">White</div>
<div class="name">$white.name $!white.firstname #rank($white.rank)<br/>#if($white.country)($white.country.toUpperCase()#if($white.club), $white.club#end)#end</div>
## <div class="pin">$white.egf</div>
</div>
<div class="equal">½-½</div>
<div class="black player">
<div class="color">Black</div>
<div class="name">$black.name $!black.firstname #rank($black.rank)<br/>#if($black.country)($black.country.toUpperCase()#if($black.club), $black.club#end)#end</div>
## <div class="pin">$black.egf</div>
</div>
</div>
<div class="signatures">
<div class="signature">Signature:</div>
<div class="equal">&nbsp;&nbsp;&nbsp;</div>
<div class="signature">Signature:</div>
</div>
</div>
#if($foreach.index % 4 == 3)
</div>
#end
#set($table = $i + 1)
<tr>
<td>Table $table</td>
<td>$black.name $!black.firstname</td>
<td>#rank($black.rank)</td>
<td>$white.name $!white.firstname</td>
<td>#rank($white.rank)</td>
<td>$game.h</td>
<td>$komi</td>
</tr>
#end
</table>
</div>
<script type="text/javascript">
onLoad(() => {

View File

@@ -69,7 +69,7 @@
</button>
</div>
<div>
<div id="paired" class="multi-select" title="white vs. black">
<div id="paired" class="multi-select" title="white vs. black">##
#foreach($game in $games)
#set($white = $pmap[$game.w])
#set($black = $pmap[$game.b])
@@ -80,10 +80,13 @@
<div class="black" data-id="$game.b">#if($black)$black.name#if($black.firstname) $black.firstname#end#{else}BIP#end</div>
<div class="handicap" data-value="$game.h">#if($game.h)h$game.h#{else}&nbsp;#end</div>
</div>
#end
</div>
#end##
#* *#</div>
#if(!$tour.type.startsWith('TEAM'))
<div class="result-sheets"><a href="result-sheets?id=${tour.id}&round=${round}" target="_blank" class="ui mini floating icon button">result sheets <i class="fa fa-external-link"></i></a></div>
<div class="pairing-post-actions">
<a href="result-sheets?id=${tour.id}&round=${round}" target="_blank" class="ui mini floating icon button">result sheets <i class="fa fa-external-link"></i></a>
<a href="explain?id=${tour.id}&round=${round}" target="_blank" class="ui mini floating icon button">explain pairing <i class="fa fa-external-link"></i></a>
</div>
#end
</div>
</div>

View File

@@ -18,21 +18,21 @@
<table id="results-table" class="ui celled striped table">
<thead class="centered">
<th data-sort-method="number">table</th>
<th>white</th>
<th>black</th>
<th>white</th>
<th>hd</th>
<th>result</th>
</thead>
<tbody>
#set($dispRst = {'?':'?', 'w':'1-0', 'b':'0-1', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'})
#set($dispRst = {'?':'?', 'w':'0-1', 'b':'1-0', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'})
#foreach($game in $individualGames)
#set($white = $plmap[$game.w])
#set($black = $plmap[$game.b])
#if($black && $white)
<tr id="result-$game.id" data-id="$game.id">
<td data-sort="$game.t">${game.t}.</td>
<td class="white player #if($game.r == 'w' || $game.r == '#') winner #elseif($game.r == 'b' || $game.r == '0') looser #end" data-id="$white.id" data-sort="$white.name#if($white.firstname) $white.firstname#end"><span>#if($white)$white.name#if($white.firstname) $white.firstname#end #rank($white.rank)#{else}BIP#end</span></td>
<td class="black player #if($game.r == 'b' || $game.r == '#') winner #elseif($game.r == 'w' || $game.r == '0') looser #end" data-id="$black.id" data-sort="$black.name#if($black.firstname) $black.firstname#end"><span>#if($black)$black.name#if($black.firstname) $black.firstname#end #rank($black.rank)#{else}BIP#end</span></td>
<td class="white player #if($game.r == 'w' || $game.r == '#') winner #elseif($game.r == 'b' || $game.r == '0') looser #end" data-id="$white.id" data-sort="$white.name#if($white.firstname) $white.firstname#end"><span>#if($white)$white.name#if($white.firstname) $white.firstname#end #rank($white.rank)#{else}BIP#end</span></td>
<td class="handicap centered">$!game.h</td>
<td class="result centered" data-sort="$game.r" data-result="$game.r">$dispRst[$game.r]</td>
</tr>

View File

@@ -30,6 +30,9 @@
</div>
</form>
</div>
#if($tour.type.startsWith('TEAM'))
<div class="strong">Team Standings</div>
#end
<div id="standings-container" class="roundbox">
#set($standings = $api.get("tour/${params.id}/standings/$round"))
#if($standings.isObject() && ($standings.error || $standings.message))
@@ -105,6 +108,78 @@
</tbody>
</table>
</div>
#if($tour.type.startsWith('TEAM'))
<div class="strong">Individual Standings</div>
<div id="individual-standings-container" class="roundbox">
#set($indvstandings = $api.get("tour/${params.id}/standings/$round?individual_standings=true"))
#if($indvstandings.isObject() && ($indvstandings.error || $indvstandings.message))
#if($indvstandings.error)
#set($error = $indvstandings.error)
#else
#set($error = $indvstandings.message)
#end
<script type="text/javascript">
onLoad(() => {
showError("$error")
});
</script>
#set($indvstandings = [])
#end
#set($indvsmap = {})
#foreach($part in $indvstandings)
#set($indvsmap[$part.num] = $part)
#end
<table id="individual-standings-table" class="ui striped table">
<thead>
<th>Num</th>
<th>Plc</th>
<th>Name</th>
<th>Rank</th>
<th>Ctr</th>
<th>Nbw</th>
#foreach($r in [1..$round])
<th>R$r</th>
#end
#set($indvcriteres = ['NBW'])
#foreach($crit in $indvcriteres)
<th>$crit</th>
#end
</thead>
<tbody>
#foreach($part in $indvstandings)
<tr data-id="$part.id">
<td>$part.num</td>
<td>$part.place</td>
<td>$esc.html($part.name)#if($part.firstname) $esc.html($part.firstname)#end</td>
<td data-sort="$part.rank">#rank($part.rank)</td>
<td>#if($part.country)$part.country#end</td>
<td>$number.format('0.#', $part.NBW)</td>
#set($mx = $round - 1)
#foreach($r in [0..$mx])
#set($rst = $part.results[$r])
#set($opp_num = $math.toLong($rst))
#if($opp_num)
#set($opponent = $!indvsmap[$opp_num])
#else
#set($opponent = false)
#end
#if($rst.contains('+'))
#set($rst = "<b>$rst</b>")
#elseif($rst.contains('-'))
#set($rst = "<i>$rst</i>")
#end
<td class="nobreak game-result" #if($opponent)title="$esc.html($opponent.name)#if($opponent.firstname) $esc.html($opponent.firstname)#end #rank($opponent.rank)#if($opponent.country) $opponent.country#end"#end>$rst</td>
#end
#foreach($crit in $indvcriteres)
#set($value = "$number.format('0.#', $part[$crit])")
<td data-sort="$value">$value.replace('.5', '½')</td>
#end
</tr>
#end
</tbody>
</table>
</div>
#end
<div class="right form-actions">
#if(!$tour.frozen && $round == $tour.rounds)
<button id="freeze" class="ui orange floating right labeled icon button">